Skip to content

One Entity Route Template for Every Department in Next.js

Adityo Guni Waluyo

Three duplicated route sets became one parameterized view layer: a pillar registry, thin routes per department, and canonicals that follow the module base.

TL;DR

Department links pointed to incorrect pages. A pillar registry and shared views fixed the issue. The refactor removed 800 lines of code.

The report came in as one sentence: "aren't all the department links still pointing to pariwisata?" A quick check on the live site confirmed it. Non-tourism pillars deep-linked their entity details to /pariwisata/*, while the category links in their mega-menu landed on 404 pages. Two wrong directions at once.

Three copies that should never have existed

The first guess was a single misconfigured link. The reality was systemic. Since the portal's tourism-centric era, listing and detail pages had been copy-pasted into other departments' routes without parameterization, leaving three copies of the same code drifting apart. As the department structure grew, the copies stopped keeping up: menus pointed at categories whose routes were never created, and detail pages borrowed another department's namespace. Patching links one by one was a dead end. The pattern itself had to go.

A pillar registry and shared views

The fix has two layers. First, a small pillar registry holding {base, module, label} for every department: one source of truth for the URL prefix, the data module, and the display label. Second, the listing and detail pages were extracted into parameterized server views in entity-views.tsx, which take a pillar as an argument and produce breadcrumbs, canonical metadata through generateMetadata, JSON-LD, share text, and per-module attribute sets.

Six new thin routes {culture,sports,youth}/[category]/[slug] simply delegate to those views. The old tourism route became a wrapper binding the tourism pillar, producing byte-identical output; the canonical URL and the "Wisata" breadcrumb were verified live. A thin route now reads about as short as this:

// app/culture/[category]/[slug]/page.tsx (simplified)
import { PILLARS } from "@/lib/pillar";
import { EntityDetail, entityMetadata } from "@/components/entity-views";

export async function generateMetadata(props) {
  const { category, slug } = await props.params;
  return entityMetadata(PILLARS.CULTURE, category, slug);
}

export default async function Page(props) {
  const { category, slug } = await props.params;
  return <EntityDetail pillar={PILLARS.CULTURE} category={category} slug={slug} />;
}

All of it rests on stock App Router conventions. A segment may be static (a literal value matched exactly) or dynamic (a placeholder that captures a value from the URL), and captured values reach the page through the params prop [1]. A page is always the leaf of the route subtree and is required for a segment to be publicly accessible; pages are Server Components by default [2], the right shape for data-driven views without client state. params itself is a promise resolving to the dynamic route parameters of the current route [5], so every view is written async.

Metadata and sitemap follow the module base

Canonical URLs and structured data follow the pillar base, not the legacy namespace. A generateMetadata that can be prerendered without dynamic behavior lands its metadata in the initial HTML [3]; crawlers read the canonical tag without waiting for hydration. On the sitemap side, the sitemap.(js|ts) convention exports a default function returning an array of URLs [4]. After the refactor, entity entries are emitted under each module's own base instead of piling up under /pariwisata.

Static routes still win

One trap is easy to miss: precedence. Dedicated pages like the sports facilities index must keep rendering themselves instead of being swallowed by the [category] segment. Next.js matches literal segments before dynamic placeholders [1], so a static folder sitting next to the dynamic one stays in charge. The test is one click: open the static route after refactoring and confirm it is not showing the generic listing. Where listings should be prerendered, generateStaticParams fills dynamic segments at build time instead of on-demand at request time [6].

The byte-identical wrapper doubled as the migration safety net: the old tourism route calls the same views with the same binding, so its correctness was checked against an unchanged canonical and breadcrumb, not against optimism. Final verification was mechanical: zero tsc errors, clean lint, 86 of 86 vitest tests passing, and a live probe showing /kebudayaan/sanggar-seni responding 200 where it was 404 before, with the /pariwisata canonical untouched. The extraction removed roughly 800 lines. The healthiest refactor is the one whose output does not change, and duplicated routes are almost always debt that gets collected exactly when the structure starts growing.

Sources

[1] Dynamic Route Segments, Next.js Docs [2] page.js, Next.js Docs [3] generateMetadata, Next.js Docs [4] sitemap.xml, Next.js Docs [5] route.js, Next.js Docs [6] generateStaticParams, Next.js Docs

Related articles