Skip to main content
ASoc
Tutorial

Next.js Dynamic Routes: 251 Pages From Two Segments, and the Seven We Didn't Build

A dynamic segment is easy. Deciding which URLs deserve one is the work. Read from a real build: 288 prerendered pages, the dynamicParams asymmetry we found, and why seven hub pages stayed files.

The ASoc Team11 min read

A dynamic route in the Next.js App Router is a folder named [slug], and generateStaticParams is what decides whether it prerenders or runs per request. But the interesting decision is not how to write one — it is which URLs deserve one. This site has 296 prerendered pages, 259 of them from three dynamic segments, and seven pages that are deliberately seven hand-written files instead.

Everything below is read out of a real production build of this repository, not sketched for the article.

The two-minute version

A dynamic segment is a directory in brackets. The value arrives in params, which in Next.js 16 is a Promise you must await:

// src/app/templates/[slug]/page.tsx
type Params = { slug: string };

export function generateStaticParams(): Params[] {
  return catalog.map((p) => ({ slug: p.slug }));
}

export default async function TemplateDetailPage({
  params,
}: {
  params: Promise<Params>;
}) {
  const { slug } = await params;
  const product = getProduct(slug);
  if (!product) notFound();
  return <TemplateDetailTemplate product={product} />;
}

That is the whole mechanism. generateStaticParams returns the list of values to build; each one becomes a static HTML file. notFound() handles a value that reached the route anyway. If you are migrating, this is the function that replaced getStaticPaths — and its fallback option is now the separate dynamicParams export, which matters more than it looks.

What actually got built

The route table from npm run build on this repo, trimmed to the interesting rows:

Route (app)
┌ ○ /
├ ƒ /api/download
├ ○ /blog
├ ● /blog/[slug]                       [+71 more paths]
├ ● /blog/-/opengraph-image            [+71 more paths]
├ ƒ /dashboard
├ ○ /nextjs-admin-template
├ ○ /react-admin-template
├ ○ /tailwind-shop-template
├ ○ /templates
├ ● /templates/[slug]                  [+108 more paths]
└ ○ /terms

○  (Static)   prerendered as static content
●  (SSG)      prerendered as static HTML (uses generateStaticParams)
ƒ  (Dynamic)  server-rendered on demand

Three markers, and the difference between them is the entire subject:

MarkerMeaningCount hereExamples
StaticA file, no params37 pages/pricing, /docs, the seven spokes
SSGA dynamic segment, prerendered from generateStaticParams3 segments, 259 pages/templates/[slug] (111), /blog/[slug] (74), the blog OG images (74)
ƒ DynamicServer-rendered per request8 routes/dashboard, /login, /api/download

Two observations people get wrong here. First, ƒ is not caused by the brackets. /login has no dynamic segment at all and is still ƒ, because it reads the Supabase session. /templates/[slug] is nothing but a dynamic segment and is fully static. Dynamic segment and dynamic rendering are unrelated axes that share a word.

Second, /blog/[slug]/opengraph-image appears as its own row with its own 74 paths. A metadata route under a dynamic segment needs its own generateStaticParams — the page's does not cover it, and without it every social card renders per request. That trap has its own write-up in dynamic Open Graph images with ImageResponse.

The export most tutorials skip: dynamicParams

Here is a real asymmetry in this codebase, which we found while writing this post. Our blog route has this:

// src/app/blog/[slug]/page.tsx
/** Nothing outside `generateStaticParams` exists — no on-demand rendering. */
export const dynamicParams = false;

Our product route does not. Both routes 404 correctly for an unknown slug, so nothing is broken — but they get there differently:

/blog/nope (dynamicParams = false)/templates/nope (default true)
What runsNothing. Not a known param, so the route does not matchThe page function runs, getProduct misses, notFound() throws
WhereThe edge/CDN layerA server invocation
Result404404
Cost of a URL scanner hammering itZeroOne invocation per bogus URL

Both are correct. false is the right default when the full set of values is known at build time — which for us is true on both routes, since generateStaticParams maps over a compile-time array in src/data/catalog.ts. Leave it true when you genuinely want new values to render on first request (an incrementally-built catalog fed by a CMS, say). We have left the asymmetry in place for now rather than change a live route's behaviour in a post about it, but the reasoning is worth stealing: dynamicParams is a statement about whether your param list is complete, and you should know the answer.

The seven pages we did not make dynamic

The most useful thing in this codebase for anyone learning dynamic routes is a place we chose not to use one.

Seven root-level URLs — /nextjs-admin-template, /react-admin-template, /tailwind-shop-template and four siblings — are the catalog's category hubs. They are structurally identical. Every one of them is a 23-line file that looks like this:

// src/app/nextjs-admin-template/page.tsx
const content = requireFrameworkLandingPage("nextjs-admin-template");

export const metadata: Metadata = frameworkLandingMetadata(content);

export default function NextjsAdminTemplatePage() {
  return (
    <>
      {frameworkLandingJsonLd(content).map((data, i) => (
        <JsonLd key={i} data={data} />
      ))}
      <FrameworkLandingTemplate content={content} />
    </>
  );
}

Seven near-identical files is exactly the shape that makes a developer reach for src/app/[spoke]/page.tsx. We did not, for three reasons in increasing order of importance.

1. There is no duplication to remove. The content is already derived from one module. frameworkLandingProducts() computes each hub's backing products from the catalog by category and framework:

// src/data/frameworkLanding.ts
export function frameworkLandingProducts(
  content: FrameworkLandingContent,
): TemplateProduct[] {
  return catalog.filter(
    (product) =>
      product.category === content.category &&
      product.editions.some(
        (edition) =>
          edition.status === "ready" &&
          (content.framework === null ||
            edition.framework === content.framework),
      ),
  );
}

A dynamic segment would not eliminate a single line of content logic. It would replace seven three-line bodies with one three-line body plus a params lookup plus a notFound() branch. That is not less code, and it is one more indirection between a URL and the file that serves it.

2. A root-level dynamic segment owns every unmatched URL. Static segments win over dynamic ones, so /pricing would still resolve to app/pricing/page.tsx — that part is safe. But /wp-admin, /favicon-old.png and every typo would now enter your route, and whether they 404 becomes a property of your code rather than of the router. With dynamicParams = false that is handled, and if you forget it you have quietly made the 404 page conditional on a lookup you wrote. A nested segment like /templates/[slug] has no such problem: the templates/ prefix already scopes it.

3. Seven is not a pattern, it is seven pages. They target seven distinct head terms and their copy differs. The break-even for a dynamic segment is when the value list is open — 111 products, 74 posts, an unbounded set — not when it happens to be more than one.

The rule we would give someone else: use a dynamic segment when the set of values grows without a code change; use files when the set grows by someone deciding to add a page. A new product joins /templates/[slug] by being appended to a data array. A new hub is a new page with new copy, new metadata, and a new keyword.

Route conflicts, catch-alls and the rest of the syntax

For completeness, since "next.js dynamic routes not working" is usually one of these:

SyntaxMatchesDoes not match
[slug]/a/, /a/b
[...slug]/a, /a/b/c/ (the parent segment)
[[...slug]]/, /a, /a/b/c
(group)Nothing — organisational onlyIt is not a URL segment

Two sharp edges worth naming. You cannot have two differently-named dynamic segments at the same levelapp/[slug] alongside app/[id] is a build error, because the router cannot know which name to bind. And a catch-all sibling to a static route still loses to the static route; precedence is static → dynamic → catch-all → optional catch-all, always.

Making a dynamic route SEO-complete

A prerendered [slug] page is only half the job — the metadata is per-slug too, and it comes from a sibling export that receives the same params:

export async function generateMetadata({
  params,
}: {
  params: Promise<Params>;
}): Promise<Metadata> {
  const { slug } = await params;
  const product = getProduct(slug);
  if (!product) return { title: "Template not found" };
  return {
    title: { absolute: productTitle(product) },
    description: productMetaDescription(product),
    alternates: { canonical: `/templates/${slug}` },
    openGraph: { /* … */ images: [product.screenshots[0]] },
  };
}

The alternates.canonical line is the one people forget, and on a dynamic route it is the one that matters most: a segment that can be reached with a trailing slash, a query string, or a filter parameter will otherwise present several URLs for one page. The same array that feeds generateStaticParams should also feed sitemap.ts, or you will prerender pages that nothing links to — the failure mode we hit at scale and wrote up in programmatic SEO in Next.js.

Common mistakes

MistakeSymptomFix
Using params.slug directlyType error, or a runtime undefined in Next 16const { slug } = await params — it is a Promise
No generateStaticParamsRoute shows ƒ, renders per requestReturn the full param list at build time
No generateStaticParams in opengraph-image.tsxEvery social card renders on demandThe metadata route needs its own
Leaving dynamicParams at the default when the list is closedBogus URLs cost a server invocation eachexport const dynamicParams = false
return notFound() inside a tryThe 404 is swallowed as an errorIt throws by design — see the not-found page
Two dynamic segments at one levelBuild error on [slug] vs [id]One name per level
A dynamic segment for a closed set of 7An indirection that removes no codeFiles, when pages are added by decision not by data
A root-level [slug] without dynamicParams = falseEvery typo enters your routeScope it under a prefix, or close the param list
Params in the sitemap drifting from generateStaticParamsPrerendered pages nothing links toOne array feeds both

Frequently asked questions

Does a dynamic route make my page slower? Not by itself. All 111 of our product pages are dynamic-segment pages, and every one of them is static HTML on the CDN — the in the build output. Rendering mode is decided by generateStaticParams, cookies, headers and searchParams, not by the brackets in the folder name.

How many pages can generateStaticParams build? Ours builds 259 across three segments in about five seconds with three workers. The ceiling in practice is build time and hosting limits, not the API. Past a few thousand pages, prerender the valuable subset and leave dynamicParams at true so the long tail renders on demand.

Can I use generateStaticParams and still get fresh data? Yes — that is exactly what leaving dynamicParams: true buys you, plus revalidation. Our data is a compile-time TypeScript array, so a rebuild is the update and we gain nothing from it. If your params come from a CMS, the opposite is true.

Why is /login server-rendered when it has no dynamic segment? Because it reads the Supabase session, and reading request state opts a route out of static rendering with no error and no warning. The full list of features that do that is in what silently turns a page dynamic.

Templates built on this routing shape

ASoc Apex and ASoc Clover are App Router admin dashboards whose detail views are exactly this pattern — a list route plus a [id] segment with per-record metadata. ASoc Pulse goes further with a full store back office (products, categories, orders, customers), which is four dynamic segments in one app and a good reference for how they nest.

Browse the sets: React admin templates, Next.js admin templates, Tailwind admin templates. If you are deciding whether pages should be generated at all, programmatic SEO in Next.js covers the hub-and-spoke build these routes hang off.

Keep reading

Tutorial11 min read

Next.js Error Boundaries: One File, and a Digest Nothing Read

One error.tsx covering 27 page files, no global-error.tsx, and a digest the boundary declared but discarded — the audit, and both fixes that shipped with this post.

Read more
Tutorial10 min read

Next.js Error Monitoring: 41 console.error Calls, Zero APM

No Sentry, no instrumentation.ts — 41 structured console.error calls, an ALERT-marker convention, and the error-hygiene test that keeps server detail out of the browser.

Read more