generateStaticParams for static catalogs
Next.js generateStaticParams, the build-time hook that prerenders a dynamic route segment into a static catalog under Cache Components.
Acme’s marketing blog lives at /blog/[slug] with about thirty posts, the help center at /help/[slug] with roughly eighty articles.
The editorial team touches them maybe once a week.
But under cacheComponents: true, the flag you turned on in next.config.ts, each [slug] segment is dynamic by default: its render function runs on every request.
So a help article that changes monthly re-renders for every visitor, even though its content is a fixed, knowable list.
One export fixes that: generateStaticParams, the build-time hook that turns a dynamic segment into a static catalog.
Hand Next the full list of slugs at build time and each article becomes HTML generated once and served from the CDN.
This lesson wires that production shape: materialized at build, cached, and invalidated one record at a time on edit, plus the rule for when it applies.
A dynamic segment is runtime data by default
Section titled “A dynamic segment is runtime data by default”Under Cache Components, everything is dynamic by default; a route goes static only by opting in.
A [slug] page is dynamic because Next cannot know the slug at build time.
The slug arrives in the URL of a request that hasn’t happened yet, so the safe default is to read it as runtime work and stream the result.
Here is the same help-article page two ways.
The first awaits params, reads the article, and renders it, with no generateStaticParams.
The second adds the one export that flips the segment to static.
// 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} />;}The render function runs on every request, because the slug is runtime data Next can’t predict. Next ships a static shell and streams the article in behind Suspense on every hit. Correct, but it 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} />;}Handing Next the full slug list at build turns each URL into static HTML served from the CDN. Same page, same data read; only the slug list given to the build is new.
Provide the list of slugs at build time and Next switches the segment to static generation : it generates the HTML for each URL up front and serves it without running your render function per request. Producing the HTML of one specific URL is to materialize it.
Returning the catalog, not the data
Section titled “Returning the catalog, not the data”generateStaticParams does not fetch the page. It returns only the list of param values, the catalog of URLs to build. The page still does its own data fetching, exactly as before.
It returns an array of objects, one per route to build, each 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 /help/getting-started and /help/billing-faq. A list of URLs, expressed as params, and nothing else.
It’s an async function that runs in the build environment , before any user request exists. The canonical body queries the catalog once and reshapes it:
export async function generateStaticParams() { const slugs = await listPublicHelpSlugs(); return slugs.map((slug) => ({ slug }));}A named export, discovered by name, unlike the default-exported page. It runs at build, in the Node environment, never per request.
export async function generateStaticParams() { const slugs = await listPublicHelpSlugs(); return slugs.map((slug) => ({ slug }));}Fetch the catalog, the slug list, once. This thin db/queries/ helper returns only the slug column, all this function needs. (This read is deduplicated with the page’s own build-time reads, covered near the end.)
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. Name it id and Next can’t map it to the segment.
export async function generateStaticParams() { const slugs = await listPublicHelpSlugs(); return slugs.map((slug) => ({ slug }));}The list of URLs, nothing more. generateStaticParams answers which URLs exist; the page answers what’s on each URL.
Because the key always matches the segment name, the multi-segment shapes follow directly:
| 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 named after its folder.
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 }));}Two decisions carry the weight: the key must match the folder name, and you return a list, not a record.
When a static catalog is the right call
Section titled “When a static catalog is the right call”Reach for generateStaticParams only when both conditions hold:
- The catalog is enumerable at build. You can produce every URL from data you already have: 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.
With one but not the other, the segment stays dynamic.
The yes column covers most of a public surface: marketing pages, blog posts, help articles, public profile slugs, changelog entries, docs. The test is whether a logged-out and a logged-in visitor see the page identically.
The no column, and why each fails:
- Per-user dashboards: the URL list is keyed on identity, which you don’t have at build.
- Search-results pages: the catalog is the size of the query space, effectively infinite.
- Anything keyed on session or auth state: a different correct answer per viewer, so no shared static artifact.
- Anything driven by
searchParams: dynamic regardless ofgenerateStaticParams. Not a catalog.
Size is the third consideration. The function runs once at build, and Next renders one HTML file per param. A few thousand pages is fine; tens of thousands slows the build noticeably, and there you materialize only the hot subset and render the rest on demand, covered later in this lesson.
Apply the tests in order: enumerable, then stable, then size.
Skip generateStaticParams. Leave it as the dynamic [slug] page with <Suspense> around the param-dependent content.
Content that depends on the session or searchParams can’t be one shared static artifact: a different right answer per viewer.
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 production content-page shape
Section titled “The production content-page shape”You already own the caching primitives from chapter 32: 'use cache', cacheLife, cacheTag, and revalidateTag.
generateStaticParams joins them into the canonical shape you’ll ship for a content page, three coordinated parts on one [slug] page:
generateStaticParamsmaterializes the catalog at build, pre-rendering the known set.- The page reads 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 a slug not in the build set is cheap on its second visit. - A publish webhook (or an editorial Server Action) calls
revalidateTag(helpArticleTags.record(slug), 'max')to bust only the edited article, no redeploy.
The 'use cache' directive goes on the getHelpArticle(slug) read helper, not on the page component: what you cache is the data read keyed on slug.
The page body stays a thin async server component that awaits params, calls the cached helper, and renders.
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 reaches for no caching directives itself; it 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. listPublicHelpSlugs sits beside listPublicHelpArticles(), the full-row helper the previous lesson’s sitemap 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, no redeploy. The webhook fires on save in the CMS, and the next request rebuilds just that page.
A refresher on the caching pieces, covered in depth in the Cache Components chapter:
'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; it sets the stale/refresh/expire window so you don’t hand-pick numbers.cacheTagcomes from the typedtags.tshelper you built in chapter 32, ashelpArticleTags.record(slug), never an inline string.revalidateTag(tag, 'max')takes the cache-profile argument as its second parameter, mandatory in Next 16; the single-argument form is a type error.
Note the signature, getHelpArticle(slug: string).
A 'use cache' boundary must not capture request-scoped values from its surrounding scope, so slug is passed in 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 , the reason the cacheTag/revalidateTag pair is worth wiring even on pages you already materialized at build.
Materializing only the popular slugs
Section titled “Materializing only the popular slugs”generateStaticParams doesn’t have to return every URL. 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.
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 middle ground between the plain dynamic page with Suspense and the fully materialized catalog.
One correction, because you will read the opposite in older posts: before Cache Components, returning an empty array [] 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 with 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 throws empty-generate-static-params. The build verifies the route never touches cookies(), headers(), or searchParams at runtime, and it needs at least one sample param to run that check. With no params there’s nothing to validate against, so it fails.
The practical rule is a clean either/or.
- If a route has
generateStaticParams, give it at least one real slug. - If you want every slug rendered at runtime, drop
generateStaticParamsentirely and use the plain dynamic[slug]page with Suspense from the first section.
A '__placeholder__' hack floats 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 that validation exists to catch.
This is on-demand rendering , the same mechanism whether you materialize a subset of a route’s siblings or none of them, and 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”This validation has a gap that can ship a green build into a broken production.
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. But it only validates the branches the sample params execute. A path no sample slug reaches is never run, so never checked.
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 starting with internal-, so the build never enters the internal- branch.
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. No sample slug hits it, so the build never runs this code, never sees the cookies() read, and passes. Then a real request for /help/internal-x enters the branch, touches cookies() outside any boundary, and returns a 500 in production.
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 fix is to not be in this branch at all. A route you’ve promised is a static catalog shouldn’t quietly read the request for some slugs. Drop the request-time branch 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.
This is condition #2 of the decision rule: content must be stable, not per-request. If you’re tempted to read cookies() for some slugs of a “catalog,” that route isn’t a pure catalog, and you’ve drifted out of where generateStaticParams is safe.
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 runs only 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”When Next materializes /help/getting-started, three functions read that article in the same build: generateStaticParams for the slug list, the page for the body, and generateMetadata for the title and OG card. Next deduplicates those reads across generate*, the page, and metadata, so the article is read once per build, not three times. That is why the walkthrough told you not to worry about double-fetching.
Dedup covers fetch-based reads automatically. For a non-fetch read shared across those functions, such as a raw DB call, wrap it in React’s cache() from chapter 32.
Reading older code: the getStaticPaths rename
Section titled “Reading older code: the getStaticPaths rename”Older code and tutorials split this job across two Pages Router exports: getStaticPaths declared which URLs to build, and getStaticProps supplied the data for each one. The App Router collapses both: generateStaticParams replaces getStaticPaths, and the page’s own async body replaces getStaticProps. The fetch moved into the page, so generateStaticParams returns only params.
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.
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: the known set is static, the long tail is cheap, and a single edit busts a single page.