Skip to content

Registry First, Redirect Second: Moving URLs into a /videos Tree

Adityo Guni Waluyo

Moving flat video URLs into a /videos hub tree in Next.js App Router with a category registry and a 308 permanentRedirect, verified with curl.

TL;DR

KotaPortal's flat /video/[slug] URLs collided silently once the CMS grew fields and categories, so the team moved everything to /videos/[field]/[category]/[slug]. Instead of hand-maintaining 200 redirect entries, they compute each target from a category registry and call permanentRedirect(), which serves 308s Google treats as canonical. The registry now also feeds hub pages and sitemaps.

KotaPortal's video detail pages sat at /video/[slug] for two years. The first sign the flat URL was running out of room: two entries in the CMS shared the slug rapat-pleno, one filed under transport policy, one under the city budget. The portal stores the content field as data, so the collision was silent until an editor complained that the wrong video opened. The URL carried no field and no category, while the information architecture had grown both.

The target shape was clear: /videos/[field]/[category]/[slug], with a /videos hub and an index page per field. Every old URL then needed to move permanently, and the move needed to be verifiable from the outside.

The redirect map that never got written

First instinct was the one Next.js documents first: an redirects array in next.config.js, one entry per slug. Roughly two hundred videos, two hundred lines like this.

// next.config.js — rejected approach
module.exports = {
  async redirects() {
    return [
      { source: '/video/rapat-pleno', destination: '/videos/transport/governance/rapat-pleno', permanent: true },
      // ... ~200 more
    ];
  },
};

It fails on the second line of thought, not the first. The destination depends on the API response per entity: which field the video belongs to, which category module that field maps to. A static path-based table applied before render cannot resolve that mapping, and the mapping itself changes when editors re-file a video. Anything hand-maintained would silently rot. The next.config.js route stays the right tool for path-pattern rules with static targets; this was not one of those.

Where the redirect actually lives

The build that shipped is data-driven. A category module registry maps each category module to its field slug and label, and it is the single source the redirect, the sitemap, and the hub pages all read from.

// lib/videos/category-registry.ts
export type CategoryModule = { field: string; label: string };

export const categoryModules: Record<string, CategoryModule> = {
  governance: { field: 'transport', label: 'Transport Policy' },
  budget: { field: 'finance', label: 'City Budget' },
  // validated at module load; unknown keys throw
};

export function videoTarget(entity: VideoEntity): string {
  const mod = categoryModules[entity.categoryModule];
  if (!mod) throw new Error(`unmapped category module: ${entity.categoryModule}`);
  return `/videos/${mod.field}/${entity.categoryModule}/${entity.slug}`;
}

The old route branch keeps rendering nothing. In the Server Component for /video/[slug], it fetches the entity, computes the target from the registry, and calls permanentRedirect().

// app/video/[slug]/page.tsx
import { permanentRedirect } from 'next/navigation';
import { videoTarget } from '@/lib/videos/category-registry';

export default async function VideoPage({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  const entity = await getVideo(slug); // 404s inside if unknown
  permanentRedirect(videoTarget(entity));
}

Two properties of permanentRedirect() make this shape work [1]. It runs in Server Components, Client Components, Route Handlers, and Server Functions. Outside Server Actions it serves HTTP 308 Permanent, and it throws NEXT_REDIRECT, terminating rendering of the route segment, so there is no unreachable return to explain to TypeScript: the function's return type is never [1].

Pick permanentRedirect() over redirect() deliberately. The Next.js redirect matrix lists redirect() as 307 (303 inside Server Actions) and permanentRedirect() as 308, and states the rule plainly: choose permanent only when the move is certain and will not be reverted [3]. This one is certain; the flat URL is deleted from the product.

301, 308, and what Google does with either

308 carries the same meaning as 301 for crawlers. Google Search Central documents that Googlebot treats permanent redirects, 301 and 308 alike, as a signal that the redirect target is the canonical page, and shows the new target in search results; temporary redirects keep the source page listed [2]. So the status code choice here decides whether search results point at the tree or keep pointing at a URL that no longer serves content.

The rest of the move follows the registry.

  1. Extend the sitemap generator so only video display-mode entries emit the new /videos/[field]/[category]/[slug] URLs. Non-video entries stay untouched.
  2. Update internal links across article bodies and the hub pages to point at the tree directly. A redirect is a safety net for external inbound links, not an internal routing strategy.
  3. Verify externally before touching Search Console:
$ curl -sI https://kotaportal.example.com/video/rapat-pleno
HTTP/1.1 308 Permanent Redirect
location: /videos/transport/governance/rapat-pleno

Expected output: status 308 and a location header matching the registry-derived path. A 307 means the wrong helper got used. A 200 means the old branch still renders content and the redirect never runs.

After that, the timeline is Google's: recrawl of the old URLs, canonical signal consolidation to the target pages, and search results switching over at whatever pace the site's crawl budget allows [2]. Nothing else to configure.

The registry earned its keep beyond redirects. The same module now feeds the field hub pages and the sitemap, so a re-filed video moves everywhere at once. 39 tests cover the mapping, the redirect target construction, and the sitemap filtering; 641 pass overall. No per-URL table exists anywhere in the repo.

Sources

  • [1] https://nextjs.org/docs/app/api-reference/functions/permanentRedirect — Next.js Docs: permanentRedirect
  • [2] https://developers.google.com/search/docs/crawling-indexing/301-redirects — Google Search Central: Redirects and Google Search
  • [3] https://nextjs.org/docs/app/building-your-application/routing/redirecting — Next.js Docs: Redirecting

Related articles