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.
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:
| Marker | Meaning | Count here | Examples |
|---|---|---|---|
○ Static | A file, no params | 37 pages | /pricing, /docs, the seven spokes |
● SSG | A dynamic segment, prerendered from generateStaticParams | 3 segments, 259 pages | /templates/[slug] (111), /blog/[slug] (74), the blog OG images (74) |
ƒ Dynamic | Server-rendered per request | 8 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 runs | Nothing. Not a known param, so the route does not match | The page function runs, getProduct misses, notFound() throws |
| Where | The edge/CDN layer | A server invocation |
| Result | 404 | 404 |
| Cost of a URL scanner hammering it | Zero | One 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:
| Syntax | Matches | Does not match |
|---|---|---|
[slug] | /a | /, /a/b |
[...slug] | /a, /a/b/c | / (the parent segment) |
[[...slug]] | /, /a, /a/b/c | — |
(group) | Nothing — organisational only | It is not a URL segment |
Two sharp edges worth naming. You cannot have two differently-named dynamic segments at the same level — app/[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
| Mistake | Symptom | Fix |
|---|---|---|
Using params.slug directly | Type error, or a runtime undefined in Next 16 | const { slug } = await params — it is a Promise |
No generateStaticParams | Route shows ƒ, renders per request | Return the full param list at build time |
No generateStaticParams in opengraph-image.tsx | Every social card renders on demand | The metadata route needs its own |
Leaving dynamicParams at the default when the list is closed | Bogus URLs cost a server invocation each | export const dynamicParams = false |
return notFound() inside a try | The 404 is swallowed as an error | It throws by design — see the not-found page |
| Two dynamic segments at one level | Build error on [slug] vs [id] | One name per level |
| A dynamic segment for a closed set of 7 | An indirection that removes no code | Files, when pages are added by decision not by data |
A root-level [slug] without dynamicParams = false | Every typo enters your route | Scope it under a prefix, or close the param list |
Params in the sitemap drifting from generateStaticParams | Prerendered pages nothing links to | One 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.
