generateStaticParams for static catalogs
Next.js generateStaticParams, the build-time hook that turns a dynamic route segment into a prerendered static catalog under Cache Components.
Acme’s marketing blog lives at /blog/[slug] with about thirty posts, and the help center lives at /help/[slug] with roughly eighty articles. The editorial team touches them maybe once a week. Under cacheComponents: true, the flag you turned on in The typed next.config.ts to opt into the Cache Components rendering model, each of those [slug] segments is dynamic by default, which means its render function runs on every request.
So a help article that changes monthly re-renders itself for every single visitor. The content is a fixed, knowable list that almost never moves, so why is it paying a per-request cost? One export answers that: generateStaticParams, the build-time hook that turns a dynamic segment into a static catalog. When you hand Next the full list of slugs at build time, each article becomes HTML generated once and served from the CDN. By the end of this lesson you’ll wire the production shape for a content page, one that is materialized at build, cached, and invalidated one record at a time on edit, and you’ll be able to state the rule for exactly when it applies.
This chapter has been about the project surface that sits outside the route tree: config, images, fonts, and the metadata files. This last lesson reaches back into the route tree for the one build-time hook that lives there.
A dynamic segment is runtime data by default
Section titled “A dynamic segment is runtime data by default”You already know the Cache Components rule: everything is dynamic by default, and a route goes static only by opting in. The new fact for this lesson is that a [slug] page is dynamic for a specific reason, which is that Next cannot know the slug at build time. The slug is, by definition, runtime data, because it arrives in the URL of a request that hasn’t happened yet. So the platform’s safe default is to treat reading that param as runtime work and stream it.
Here is the same help-article page two ways. The first tab is the naive shape: it awaits params, reads the article, and renders it, with no generateStaticParams. The second adds the one export that flips the segment to static. Compare them.
// app/(marketing)/help/[slug]/page.tsxexport default async function Page({ params,}: { params: Promise<{ slug: string }>;}) { const { slug } = await params; const article = await getHelpArticle(slug); return <HelpArticle article={article} />;}Works, but the render function runs on every request, because the slug is runtime data Next can’t predict. Next ships a static shell and the article content streams in behind Suspense on every hit. That’s correct; it just pays a per-request cost for a catalog that barely changes.
export async function generateStaticParams() { const slugs = await listPublicHelpSlugs(); return slugs.map((slug) => ({ slug }));}
export default async function Page({ params,}: { params: Promise<{ slug: string }>;}) { const { slug } = await params; const article = await getHelpArticle(slug); return <HelpArticle article={article} />;}The export hands Next the full slug list at build, so each URL becomes static HTML served from the CDN. Same page, same data read; the only change is the slug list handed to the build.
The change between the two tabs is one line of intent: provide the list of slugs at build time, and Next switches the segment to static generation . It generates the concrete HTML for each URL up front and serves it without ever running your render function per request. The word for producing the HTML of one specific URL is to materialize it. That single export is what separates rendering on every request from rendering once at build.
The next section opens up that export and clears up a misconception almost everyone arrives with.
Returning the catalog, not the data
Section titled “Returning the catalog, not the data”Here is the mistake to clear up first: people assume generateStaticParams fetches the page. It does not. It returns only the list of param values, the catalog of URLs to build. The page still does its own data fetching, exactly as it did before. Once you hold those two jobs apart, the rest of the lesson is easy.
The contract is small. generateStaticParams returns an array of objects, one object per route you want materialized, and each property is keyed by the dynamic segment’s name. Your folder is [slug], so each object has a slug property:
return [{ slug: 'getting-started' }, { slug: 'billing-faq' }];That tells Next to build two URLs: /help/getting-started and /help/billing-faq. That’s the whole output: a list of URLs to build, expressed as params. No titles, no bodies, no article content.
It’s an async function, and it runs in the build environment , the Node process that executes once at next build, before any user request exists. The database, the filesystem, and your upstream APIs are all reachable there. So the canonical body queries the catalog once and reshapes it:
export async function generateStaticParams() { const slugs = await listPublicHelpSlugs(); return slugs.map((slug) => ({ slug }));}The signature. This is a named export that Next discovers by its name, unlike the page, which is default-exported. It runs at build, in the Node environment, never per request.
export async function generateStaticParams() { const slugs = await listPublicHelpSlugs(); return slugs.map((slug) => ({ slug }));}The read helper fetches the catalog, the slug list, once. This is a thin db/queries/ helper that returns only the slug column, because the list of URLs is all this function needs. (Don’t worry about double-fetching: this read is automatically deduplicated with the page’s own reads at build, and there’s a short section near the end on that.)
export async function generateStaticParams() { const slugs = await listPublicHelpSlugs(); return slugs.map((slug) => ({ slug }));}Shape each slug into a param object. The key must match the folder name: the folder is [slug], so the property is slug. If you name it id instead, Next can’t map it to the segment.
export async function generateStaticParams() { const slugs = await listPublicHelpSlugs(); return slugs.map((slug) => ({ slug }));}This is the list of URLs to build, and nothing more. generateStaticParams answers which URLs exist; the page answers what’s on each URL.
That division of labor is the whole mental model, so here it is as a rule you can carry: generateStaticParams answers “which URLs exist”; the page answers “what’s on each URL.” Both run at the same phase, the build, but they do different jobs. Reading the article body inside generateStaticParams is a mistake, because the body belongs in the page.
The key always matches the segment name, which is why the multi-segment shapes fall out naturally. You won’t need these often on a SaaS, but the shape is worth recognizing:
| Route | Return type |
| --- | --- |
| /help/[slug] | { slug: string }[] |
| /[category]/[product] | { category: string, product: string }[] |
| /help/[...slug] (catch-all) | { slug: string[] }[] |
One object per URL, one property per dynamic segment, each property named after its folder. (Generating params for nested parent-child segments has its own bottom-up rules, but that’s a niche case you can look up in the docs if you ever hit it.)
Now wire the catalog yourself. The page below lives at help/[slug]. Fill in the three blanks so the build-time catalog is correct:
Complete the build-time catalog. The page lives at `help/[slug]`. Pick the right option from each dropdown, then press Check.
export ___ function generateStaticParams() { const slugs = await ___(); return slugs.map((slug) => ({ ___: slug }));}The two decisions that carry weight there are that the key must match the folder name, and that you return a list rather than a record. Get those right and the catalog is correct.
When a static catalog is the right call
Section titled “When a static catalog is the right call”Writing generateStaticParams is the easy half. The harder and more durable skill is knowing when to reach for it, and the answer is a rule with two conditions, both required:
- The catalog is enumerable at build. You can produce the complete list of URLs from data you already have at build time: a database table, a content directory, a CMS export.
- The content is stable between deploys. It changes on an editorial or release cadence, not per request and not per user.
Both must hold. With one but not the other, the segment belongs back in the dynamic-by-default bucket.
The yes column is most of a SaaS’s public surface: marketing pages, blog posts, public help articles, public profile slugs, changelog entries, and docs. The test is whether a logged-out visitor and a logged-in visitor would see the page identically.
The no column, with the reason each one fails:
- Per-user dashboards are not enumerable. The “list of URLs” is keyed on identity, and you don’t have the user at build.
- Search-results pages have a catalog the size of the query space, which is effectively infinite.
- Anything keyed on session or auth state can’t become a single shared static artifact, because there’s a different correct answer per viewer.
- Anything driven by
searchParamsis dynamic regardless ofgenerateStaticParams. That’s its own rule, the one you met when the course covered the request surface, and it means the route is not a catalog.
The one-line version to keep: if you can write down every URL ahead of time and they’d look the same to everyone, it’s a catalog.
There’s one more cost worth naming. The function runs once at build, and Next renders one HTML file per returned param. A few thousand pages is fine, but at tens of thousands the build slows noticeably. At that point you don’t materialize the whole list: you materialize the hot subset and let the rest render on demand, which the next section covers. Don’t pre-optimize for it, but do know where the threshold sits.
Walk through the questions in order, because the order itself is what matters here: enumerable, then stable, then size. No single answer decides the verdict on its own.
If the catalog isn’t enumerable, you don’t use generateStaticParams. Leave it as the dynamic-by-default [slug] page with <Suspense> around the param-dependent content, exactly as chapter 32 taught.
Content that depends on the session or searchParams can’t be one shared static artifact, because there’s a different right answer per viewer. (The searchParams case is the request-surface material.)
This is the production content-page shape with full materialization, where every URL is built at deploy. The next section wires it.
Building tens of thousands of pages slows the deploy. Materialize the head of traffic and let the long tail render on first request. The section two below covers this.
The production content-page shape
Section titled “The production content-page shape”This is where it all pays off. You already own the caching primitives from chapter 32: 'use cache', cacheLife, cacheTag, and revalidateTag. The new export combines with them into the canonical shape you’ll actually ship for a content page. There are three coordinated parts, all on one [slug] page:
generateStaticParamsmaterializes the catalog at build.- The page reads its data through a
'use cache'read helper that takesslugas an argument, tags itself withcacheTag(helpArticleTags.record(slug)), and sets a freshness window withcacheLife('days'). Each article’s HTML is now cached cross-request and tagged for precise invalidation. - A publish webhook (or an editorial Server Action) calls
revalidateTag(helpArticleTags.record(slug), 'max')to bust only the edited article.
Why this exact combination, and not just generateStaticParams on its own? Each part does a job the others can’t:
generateStaticParamsgives you the build-time set: the known articles, pre-rendered.'use cache'andcacheLifemake even a runtime-rendered slug, one not in the build set, cheap on its second visit, and give every page a freshness window.cacheTagandrevalidateTagmake a single edit invalidate exactly one page, instead of redeploying the whole site.
Taken together: build-time static for the known set, cheap and fresh for the long tail, and invalidation one page at a time on edit.
One placement detail matters, and it’s the part people get backwards. The 'use cache' directive goes on the getHelpArticle(slug) read helper, not at the top of the page component. The page runs generateStaticParams and may branch on the slug, but what you want cached is the data read, keyed on slug, so the directive belongs on the function that reads the article. The page body stays a thin async server component: it awaits the params, calls the cached helper, and renders. This is the shape Next’s own docs use, with 'use cache' sitting on the getPost(slug) read.
Here’s the shape across its three files.
export async function generateStaticParams() { const slugs = await listPublicHelpSlugs(); return slugs.map((slug) => ({ slug }));}
export default async function Page({ params,}: { params: Promise<{ slug: string }>;}) { const { slug } = await params; const article = await getHelpArticle(slug); return <HelpArticle article={article} />;}Build the catalog, then render each article through the cached read helper. The page never reaches for caching directives itself; it just awaits params and calls the helper.
import { cacheLife, cacheTag } from 'next/cache';
import { helpArticleTags } from '@/lib/tags';
export async function listPublicHelpSlugs(): Promise<string[]> { const rows = await db .select({ slug: helpArticles.slug }) .from(helpArticles) .where(eq(helpArticles.visibility, 'public')); return rows.map((row) => row.slug);}
export async function getHelpArticle(slug: string) { 'use cache'; cacheTag(helpArticleTags.record(slug)); cacheLife('days'); return db.query.helpArticles.findFirst({ where: eq(helpArticles.slug, slug), });}'use cache' lives on the data read, keyed and tagged by slug, and never captures the request. listPublicHelpSlugs lives in the same db/queries/help-articles.ts file as listPublicHelpArticles(), the full-row helper the sitemap from the previous lesson calls, so one queries file owns every read of this table.
export async function POST(request: Request) { const { slug } = await readPublishEvent(request); revalidateTag(helpArticleTags.record(slug), 'max'); return Response.json({ revalidated: true });}One edit busts exactly one article, with no redeploy. The publish webhook fires on save in the CMS and the next request rebuilds just that page.
A few things to read off that, as a quick refresher, since the Cache Components chapter covers them in depth:
'use cache'is cross-request persistence: the result survives between visitors, not just within one render.cacheLife('days')is the documented preset for editorial content like blog posts and articles; it sets the stale/refresh/expire window so you don’t hand-pick numbers.cacheTagcomes from yourtags.tshelper, ashelpArticleTags.record(slug), never an inline string. That’s the sametags.tsfile you built in chapter 32, so tag names stay typed and consistent across the codebase.revalidateTag(tag, 'max')takes the cache-profile argument as its second parameter. In Next 16 that argument is mandatory, and the single-argument form is a type error.
And notice the signature: getHelpArticle(slug: string). A 'use cache' boundary must not capture request-scoped values from its surrounding scope. That’s why slug is passed in as an argument rather than read from params inside the helper: the argument is the cache key.
Busting one record’s cache by its tag, instead of rebuilding the whole site, is surgical invalidation , and it’s the reason the cacheTag/revalidateTag pair is worth wiring even on pages you’ve already materialized at build.
Materializing only the hot paths
Section titled “Materializing only the hot paths”generateStaticParams doesn’t have to return every URL. It can return a subset. Return the hot hundred, the articles driving most of your traffic, and let the long tail render on first request. An unlisted slug renders on demand the first time someone asks for it, and Next saves that HTML to disk after a successful response, so the second visitor to a cold article also gets static HTML. It’s the same machinery as full materialization, just lazier about the cold set.
Reach for this when the catalog is large enough that building all of it slows the deploy, but a small head drives most of the traffic. It’s the deliberate middle ground between the two clean ends: the plain dynamic page with Suspense, and the fully materialized catalog.
One correction matters here, because you will read the opposite in older blog posts. Before Cache Components, returning an empty array [] from generateStaticParams meant “materialize nothing, render every URL at runtime.” That intuition is now wrong.
export async function generateStaticParams() { return [];}Pre-Cache-Components this meant “render everything at runtime.” Under cacheComponents it fails the build. The error is empty-generate-static-params.
export async function generateStaticParams() { const slugs = await listPopularHelpSlugs(); return slugs.slice(0, 100).map((slug) => ({ slug }));}Materialize the hot set; unlisted slugs render once on first request, then serve from disk.
Under cacheComponents: true, an empty array is a build error (empty-generate-static-params). The reason is that Cache Components validates at build that the route doesn’t accidentally touch cookies(), headers(), or searchParams at runtime, and it needs at least one sample param to run that validation against. With no params there’s nothing to validate, so the build fails.
So the practical rule is a clean either/or.
- If a route has
generateStaticParams, give it at least one real slug. - If you genuinely want every slug rendered at runtime, you don’t want
generateStaticParamsat all. You want the plain dynamic[slug]page with Suspense from the first section.
There’s a '__placeholder__' hack floating around that returns a fake param to satisfy the check. Don’t use it: it defeats the validation it pretends to pass and invites the exact runtime error the validation exists to catch. The two honest shapes are a catalog with real samples and a plain dynamic page, with nothing in between.
That on-demand rendering behavior, where a URL renders the first time it’s requested and the result is then cached rather than rendering at build, is the same mechanism whether you materialize a subset of a route’s siblings or none of them. It’s worth understanding on its own, because it’s what makes partial materialization safe.
What the build can and can’t see
Section titled “What the build can and can’t see”There’s a production failure here that’s easy to miss, and it follows directly from how that build-time validation works.
At build, Next runs the route once per returned param and checks that nothing reaches a request-time API (cookies(), headers(), or searchParams) outside a Suspense or 'use cache' boundary. The catch is that it can only validate the branches the sample params actually execute. A code path no sample slug reaches is a path the build never runs, and therefore never checks.
Here’s how the problem forms.
export async function generateStaticParams() { const slugs = await listPublicHelpSlugs(); return slugs.map((slug) => ({ slug }));}
export default async function Page({ params,}: { params: Promise<{ slug: string }>;}) { const { slug } = await params; if (slug.startsWith('internal-')) { const role = (await cookies()).get('staff_role')?.value; return <InternalNote slug={slug} role={role} />; } return <HelpArticle article={await getHelpArticle(slug)} />;}The samples. listPublicHelpSlugs() returns only public slugs, none of which start with internal-. So at build, the internal- branch is never executed.
export async function generateStaticParams() { const slugs = await listPublicHelpSlugs(); return slugs.map((slug) => ({ slug }));}
export default async function Page({ params,}: { params: Promise<{ slug: string }>;}) { const { slug } = await params; if (slug.startsWith('internal-')) { const role = (await cookies()).get('staff_role')?.value; return <InternalNote slug={slug} role={role} />; } return <HelpArticle article={await getHelpArticle(slug)} />;}The unvalidated branch. Because no sample slug hits it, the build never runs this code, so it never sees the cookies() read, and the build passes. Then a real request for /help/internal-x enters this branch, touches cookies() outside any boundary, and returns a 500 in production. The build is green and production is broken, which is exactly the failure that makes “it builds, so ship it” unsafe.
export async function generateStaticParams() { const slugs = await listPublicHelpSlugs(); return slugs.map((slug) => ({ slug }));}
export default async function Page({ params,}: { params: Promise<{ slug: string }>;}) { const { slug } = await params; if (slug.startsWith('internal-')) { const role = (await cookies()).get('staff_role')?.value; return <InternalNote slug={slug} role={role} />; } return <HelpArticle article={await getHelpArticle(slug)} />;}The real fix is to not be in this branch at all. A route you’ve promised the build is a static catalog shouldn’t quietly read the request for some slugs. Either drop the request-time branch entirely to keep it a pure catalog, or, if it truly must read cookies(), wrap that branch in <Suspense> so the runtime read is explicitly allowed.
The fix is one of two moves. Either wrap the request-API read in a <Suspense> boundary so it’s allowed to be runtime, or, better for something you’re trying to keep static, don’t branch into request-time data on that page at all.
This is exactly what condition #2 of the decision rule protects you from: content that is stable rather than per-request or per-user. If you find yourself tempted to read cookies() for some slugs of a “catalog,” that route probably isn’t a pure catalog, and you’ve drifted out of the territory where generateStaticParams is safe.
Test your read of the validation model:
A /help/[slug] page has generateStaticParams returning [{ slug: 'pricing' }, { slug: 'faq' }], plus a branch that reads cookies() only when slug === 'admin-preview'. next build succeeds. What happens at runtime?
cookies() is banned on any route that exports generateStaticParams./help/admin-preview then errors at runtime.admin-preview branch is silently rendered as static HTML at build, alongside pricing and faq.cookies() resolves to an empty value at build, so every slug — including admin-preview — renders fine.'pricing' and 'faq' never enter the admin-preview branch, so the cookies() read is never exercised — and never flagged. The unvalidated path only runs when a real request for /help/admin-preview arrives, and that’s where it breaks.One read, shared across the build
Section titled “One read, shared across the build”Back in the walkthrough I told you not to worry about double-fetching. Here’s why. When Next materializes /help/getting-started, three functions can read that article: generateStaticParams reads the slug list, the page reads the article, and generateMetadata reads it again for the title and OG card. They run at the same build, against the same slug, and Next deduplicates those reads automatically across generate*, the page, and metadata. The article is read once per build, not three times. (generateMetadata from the previous lesson is part of this, since it runs at build for every materialized param and shares the same read.)
That automatic dedup covers fetch-based reads out of the box. If you ever need it for a non-fetch read, such as a raw DB call shared across generateStaticParams, the page, and generateMetadata, the explicit tool is React’s cache() from chapter 32. The common case needs nothing extra.
Reading older code: the getStaticPaths rename
Section titled “Reading older code: the getStaticPaths rename”You’ll open a tutorial or an old codebase and see two functions that look like a different mechanism. They aren’t: they’re the Pages Router ancestor of what you just learned, and recognizing them saves you time.
The Pages Router split this job across two exports. getStaticPaths declared which URLs to build, the same job as generateStaticParams, and getStaticProps supplied the data for each one. The App Router collapses both: generateStaticParams replaces getStaticPaths, and the page’s own async body plus its cached read helper replace getStaticProps. The data fetch moved into the page, which is exactly why generateStaticParams returns only params. The old fallback: 'blocking' | true | false option maps roughly onto today’s on-demand behavior for unlisted slugs. Recognize the old shape, reach for the modern one, and move on.
External resources
Section titled “External resources”The official return-shape contract, multi-segment forms, and the dynamicParams options.
How sampled params validation works — the source for the green-build / red-production trap — alongside 'use cache' and tags.
ByteGrad's 34-minute tour of every Next.js rendering mode, building from npm run build to generateStaticParams, ISR, and PPR.
Vercel's deployment-side view of the stale-while-revalidate model the lesson's cacheLife and revalidateTag express.
That closes the chapter. You now have the whole map of the project surface outside the route tree: the typed config, the image, font, and script pipelines, and the metadata and SEO files, plus the one build-time hook that reaches back into it. The point to leave with is that under Cache Components a route is dynamic until you prove it’s a catalog, and generateStaticParams is how you make that promise. Pair it with 'use cache', a tag, and a freshness window, and the known set is static, the long tail is cheap, and a single edit busts a single page.