Skip to content
Chapter 34Lesson 8

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.tsx
export 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.

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.

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.

1 / 1

Because the key always matches the segment name, the multi-segment shapes follow directly:

RouteReturn 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.

Reach for generateStaticParams only when both conditions hold:

  1. 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.
  2. 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 of generateStaticParams. 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.

Should this segment be a static catalog?

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:

  1. generateStaticParams materializes the catalog at build, pre-rendering the known set.
  2. The page reads through a 'use cache' read helper that takes slug as an argument, tags itself with cacheTag(helpArticleTags.record(slug)), and sets a freshness window with cacheLife('days'). Each article’s HTML is now cached cross-request, and a slug not in the build set is cheap on its second visit.
  3. 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.

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.
  • cacheTag comes from the typed tags.ts helper you built in chapter 32, as helpArticleTags.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.

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.

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 generateStaticParams entirely 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.

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.

1 / 1

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?

The build should have failed — cookies() is banned on any route that exports generateStaticParams.
The build passes because no sample slug enters that branch; a request to /help/admin-preview then errors at runtime.
The 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.

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.

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.