Teaching the Build About Your Dynamic Segments
A route like app/blog/[slug]/page.tsx is a pattern, not a page — the build cannot know that /blog/why-postgres exists unless you tell it. generateStaticParams is that telling: a function that returns the list of parameter combinations to prerender at compile time. Each returned object becomes one concrete HTML file baked into the build output.
The shape of the return value is dictated by the folder names, exactly as it is at request time:
// app/blog/[slug]/page.tsx
import { db } from "@/lib/db";
export async function generateStaticParams() {
const posts = await db.post.findMany({
where: { published: true },
select: { slug: true },
});
return posts.map((post) => ({ slug: post.slug }));
// → [{ slug: "why-postgres" }, { slug: "cache-invalidation" }, ...]
}
export default async function PostPage(props: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await props.params;
const post = await db.post.findUnique({ where: { slug } });
if (!post) notFound();
return <Article post={post} />;
}
Two version details hide in that file. In Next.js 15, params is a Promise the page awaits (in 14 it was a plain object) — the prerendered pages run this component once each during the build, so any await here executes at build time, not request time. And the same await params contract applies to the route that uses it: a dynamic segment with no generateStaticParams is not an error in a normal build — it simply prerenders nothing and renders every request on demand (the route shows ƒ, or ● when some paths were listed). The exception is output: "export" (fully static hosting), where the build fails with Page "/blog/[slug]" is missing "generateStaticParams()" so it cannot be used with "output: export" — there, the function is mandatory because there is no server to render on demand.
Multiple and nested parameters multiply correctly. For app/shop/[category]/[product]/page.tsx, return one object per full combination:
return products.map((p) => ({ category: p.category, product: p.slug }));
// [{ category: "books", product: "9780132350884" }, ...]
Catch-all segments take arrays: { slug: ["docs", "getting-started"] } becomes /docs/getting-started, and you must supply every leaf path — the build will not enumerate a tree for you. An optional catch-all can also return { slug: [] } to prerender the parent path itself. The matching rules are those of Dynamic Route Segments; what generateStaticParams adds is the build-time enumeration.
Practical details that bite in real builds: return a deterministic list — the function runs on every build, so ordering and membership should come from a stable query, or your diff shows pages shuffling for no reason. If two functions in the same tree provide params (a layout's generateStaticParams and a page's), the page's values are filtered against the layout's — a useful guard for "don't build products of unpublished categories," and a confusing error source when the filtering silently empties the list. And mind build duration: 5,000 paths × a 200 ms render ≈ 17 minutes of serial prerendering (the build parallelizes, but the data layer becomes the bottleneck). That number is the entry fee for the economics in the next chapter — and the reason large catalogs reach for on-demand generation or Incremental Static Regeneration instead of one monolithic build.
The build confirms everything. Watch the route table print ● /blog/[slug] with a listed count, and open .next/server/app/blog/why-postgres.html to see the file the CDN will eventually serve.
Why Prerendered Pages Are the Fastest Pages You Own
A prerendered page is a file. Serving it means a filesystem hit on your own server or an edge cache lookup — no React render, no database query, no session logic. That is why statically generated pages cost almost nothing per request: TTFB in single-digit milliseconds at the edge, origin CPU at zero for cache hits, and a serving path so simple it rarely fails. The whole request lifecycle shrinks to routing the URL to bytes.
The economics deserve concrete numbers, because they are the argument for choosing SSG when you can. Take a documentation site with 1,000 pages. At peak it serves 300 RPS. Rendered per request at 150 ms of CPU each, that is 45 core-seconds of work per second — roughly 45 instances busy at all times. Prerendered, the same 300 RPS is served by a CDN; your origin sees only cache misses and revalidations — a handful of instances, mostly idle. The difference between the two architectures is not milliseconds; it is two orders of magnitude in serving cost.
What exactly happens during next build for a prerendered route is worth stating, because each step maps to a failure mode:
- The route's components run on the server, with their
awaits resolved against real data — this is when your CMS queries execute. - React renders the tree to HTML — the file that will be served.
- The RSC payload is serialized alongside it, so client navigation to the page hydrates without refetching everything.
- The output is manifested as a static asset, eligible for CDN caching with the cache headers Next assigns to static responses.
The failure mode of step 1 is the build-time coupling: whatever the data source returned during those minutes defines every visitor's page until the next build. A product unpublished at 2:00 pm is still for sale at 2:01 pm, because the HTML was baked at 1:00 am. Teams that feel this pain reach for ISR (refresh in the background) or on-demand rebuilds triggered from a CMS webhook; the point of this topic is to recognize the staleness window as intrinsic to the strategy, not a misconfiguration.
The second trade-off is build time, and it compounds with the parameter count. Prerendering is fan-out: your page component runs once per path, and data fetching that would have been one query per request becomes one query per build per path if you are careless — a classic bug is fetching inside the component when generateStaticParams already fetched the list, doubling CMS load at build. Fetch once, share the result through React cache(), or pass data down from the params function's own queries.
When is SSG the right answer? Run this decision list:
| Question | If yes → |
|---|---|
| Is the output identical for every visitor? | SSG is viable |
| Does it change at most a few times a day? | SSG (rebuild per publish) |
| Can it tolerate minutes of staleness? | SSG + ISR |
| Must it be current per request (per-user, per-second)? | dynamic rendering |
Two boundary notes. Prerendering and caching the response are different layers: the HTML file is static, but data fetched on the client after hydration has its own freshness rules — a prerendered shell with client-fetched live data is a legitimate hybrid. And prerendered does not mean unchangeable: the revalidate option converts any static route into the ISR regime of the next-but-one topic, which is exactly how Next.js softens SSG's staleness wall. For now, the interview-safe formulation is: SSG moves all the work to build time so that request time approaches zero — and the price is a staleness window and a build-duration budget.
Unlisted Paths: dynamicParams, the 404 Question, and the Fallback Legacy
generateStaticParams lists what to build. The question that remains is what to do when a request arrives for a path not on the list — a blog post published after this morning's build, a long-tail search bot crawling an old URL. The switch is the segment config dynamicParams, and its two states are a product decision disguised as a technical one.
// app/blog/[slug]/page.tsx
export const dynamicParams = false; // unlisted slugs → 404
// default (true): unlisted slugs → rendered on demand, then cached
With the default true, the request completes: Next renders the page on the server at request time — exactly the per-request mechanics of Server Rendering per Request — and serves it. Crucially, the on-demand result is written into the static cache, so the expensive first request is a one-time cost; subsequent requests hit the file like any prerendered page. Your route now mixes regimes: listed paths cost nothing, unlisted paths pay once. In the build table this route still shows ●, which means "some paths prerendered, the rest render on demand" — a truthful summary of a hybrid.
With false, the same request returns 404 from the static layer without executing your page at all. Choose it when unlisted paths should not exist to the outside world: private staging routes, deprecated URL schemes you are retiring, demo tenants. The security-minded phrasing matters here — a 404 from dynamicParams = false is a routing decision, not access control; anything genuinely private still needs real authorization, because the mechanism only governs rendering, not data access.
The Pages Router veteran will recognize this as the fallback flag's replacement, and the mapping is worth knowing for legacy codebases:
Pages Router (getStaticPaths) |
App Router |
|---|---|
fallback: false |
dynamicParams = false |
fallback: true |
(no direct equivalent — render-on-demand with a client fallback shell) |
fallback: "blocking" |
dynamicParams = true (the App default: the visitor waits for the render) |
The behavioral difference between old true and blocking explains why the App Router did not keep three states: true painted a shell immediately and filled in client-side, which reintroduced loading states and layout shifts the App Router's streaming model handles with loading.tsx instead. If you want the shell-first experience on an unlisted path, you combine dynamic rendering with a Suspense boundary — the material of Streaming with Suspense Boundaries.
Edge cases that generate support tickets: params objects with extra keys are rejected — returning { slug, id } for a [slug] route throws a build error listing the unexpected key, which catches copy-pasted mappers. Missing keys fail likewise (params.id is missing for route "/shop/[category]/[product]"-style messages). A generateStaticParams that returns [] is legal: it prerenders nothing, and every request renders on demand — a quiet way to disable prerendering during an incident without deleting the function. And a function marked async fetches at build time; an exception there fails the whole build loudly, which is the correct place to fail, since the alternative is shipping pages with missing data.
The complete decision, in one table:
| Situation | Setting |
|---|---|
| Finite, known, stable URL set | list all paths, keep dynamicParams = true as a safety net |
| URL set grows by publishing; new URLs must work immediately | list known paths, dynamicParams = true (first hit pays the render) |
| Unlisted URLs must be invisible | dynamicParams = false |
| URLs too numerous to list (millions) | return [], render on demand, and consider ISR for freshness |
That last row is the doorway to the next topic: pages that are static but cannot afford to wait for a rebuild.
Quiz
1. app/shop/[category]/[product]/page.tsx must prerender two full products. What does generateStaticParams return?
2. generateStaticParams lists 50 slugs; a request arrives for slug 51 with default segment config. What happens?
3. Why does export const dynamicParams = false exist?
4. A route with output: 'export' and a [slug] segment but no generateStaticParams fails the build. Why?
Flashcards
Question
What does generateStaticParams do, and when does it run?
Click to reveal answer
Answer
It returns the array of parameter combinations (one object per path, keys named after the bracket folders) that the build prerenders into static HTML. It runs at build time — its awaits execute during next build, not per request.
Question
What does a route show in the build table when some paths are prerendered and others render on demand, and what setting causes it?
Click to reveal answer
Answer
● (partial). It appears when generateStaticParams lists some paths while dynamicParams stays true (the default), so unlisted params render on demand and then enter the static cache.
Question
dynamicParams = true vs false — behavior for an unlisted path?
Click to reveal answer
Answer
true (default): the request renders on demand at request time and the result is cached for later hits. false: the request 404s before the page executes. Choose false only for URLs that should not exist publicly — it is routing policy, not access control.
Question
Map the Pages Router getStaticPaths fallback values to App Router equivalents.
Click to reveal answer
Answer
fallback: false → dynamicParams = false; fallback: 'blocking' → dynamicParams = true (App default: visitor waits for the render); fallback: true → no direct equivalent — use dynamic rendering plus a loading.tsx Suspense boundary for a shell-first experience.
Question
The two economic trade-offs of prerendering 10,000 pages?
Click to reveal answer
Answer
Build budget: every path renders during next build (10k × 200 ms ≈ 33 min of work before parallelism) and data sources are hit at build time. Staleness: whatever the data returned at build defines every visitor's page until the next build — mitigated by ISR or on-demand rebuilds, not by caching settings.
Revision Notes
Key Takeaways
- 1. generateStaticParams enumerates the paths to prerender; it runs at build time and must return objects keyed exactly like the bracket folders
- 2. Unlisted paths render on demand by default and are cached after the first hit; dynamicParams = false turns them into 404s before the page runs
- 3. Prerendered pages are the cheapest to serve (CDN file hit) but the HTML is frozen at build time — staleness is intrinsic, not a misconfiguration
- 4. Build time is the price: fan-out renders and build-time data fetches scale with path count; fetch once and dedupe with cache()
- 5. The Pages Router fallback flag maps to dynamicParams (false → false, 'blocking' → true); shell-first behavior now comes from loading.tsx
Interview Tips
- • "Walk me through what happens for a prerendered route at build and at request" — build: fetch, render to HTML, serialize RSC payload, manifest as static asset; request: file/CDN serve with zero React work — this precision separates candidates from people who say "it's cached"
- • "A new blog post 404s right after publishing" — it was not in generateStaticParams; default dynamicParams should still render it on demand, so investigate dynamicParams = false or output: export; then offer ISR or a rebuild webhook as the structural fix
- • "How would you handle 500,000 product pages?" — never one build: dynamicParams rendering on demand, ISR for background freshness, and a cache/tag strategy; quote the build-time math to show why
- • "fallback: true in Pages Router — what now?" — no direct App Router equivalent; use dynamicParams = true plus a loading.tsx Suspense boundary so unlisted paths paint a shell while data streams
Cheat Sheet
Static Generation with generateStaticParams
The contract:
export async function generateStaticParams() {
const posts = await db.post.findMany({ select: { slug: true } });
return posts.map((p) => ({ slug: p.slug })); // one object per path
}
export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await props.params; // await — params is a Promise in Next 15
}
Keys must match the bracket folders exactly; extra/missing keys fail the build with a key-level error. Catch-alls take arrays ({ slug: ["docs", "intro"] }).
Build outcomes:
| Path listed? | dynamicParams |
Result |
|---|---|---|
| yes | any | prerendered HTML file (route ○ or ●) |
| no | true (default) |
rendered on demand, then added to the static cache (route ●) |
| no | false |
404 before the page runs |
| — | — | no generateStaticParams at all: everything on demand (ƒ); output: "export" makes it a build error |
Why static wins at request time: file/CDN serving — single-digit-ms TTFB, ~0 origin CPU; 300 RPS prerendered needs an origin fleet two orders of magnitude smaller than per-request rendering.
Costs: build duration scales with path count (fan-out renders); data fetched in both generateStaticParams and the component doubles CMS load — fetch once, share via React cache(); output is frozen until the next build (staleness window).
Decision: identical for every visitor + tolerates minutes of staleness → SSG (add revalidate for ISR) · must be current per request → dynamic rendering · URLs grow by publishing → list known + dynamicParams: true.