Next.js

generateStaticParams and Static Export

How generateStaticParams works with output: 'export' to pre-render every dynamic route at build time, with patterns for nested segments and catch-all ro...

When you set output: 'export' in your Next.js config, the build has to produce a file for every URL your site will ever serve. There's no server to handle unknown paths at runtime, so Next.js needs a complete list upfront. That's what generateStaticParams is for: it's the function you export from a dynamic route to hand that list to the build.

This site uses exactly this setup. It's a statically exported Next.js 15 project deployed to Cloudflare Pages. Every notebook article, every section index, every release note is a file written to out/ at build time. generateStaticParams is what makes that possible for routes with dynamic segments.

The Basic Pattern

A dynamic route like app/notebook/[slug]/page.tsx matches a single dynamic segment at /notebook/:slug (for example, /notebook/introduction). In a server-rendered app, the slug arrives at runtime and you fetch the content then. In a static export, that can't work. You export generateStaticParams to return every slug the build should render:

// app/notebook/[slug]/page.tsx

export function generateStaticParams() {
  return [
    { slug: 'introduction' },
    { slug: 'getting-started' },
    { slug: 'advanced-patterns' },
  ]
}

export default async function Page({
  params,
}: {
  params: Promise<{ slug: string }>
}) {
  const { slug } = await params
  // fetch content for this slug
}

Next.js calls generateStaticParams once during the build, iterates over the returned array, and renders the page for each value. The result is one HTML file per slug, dropped into out/notebook/.

Note the await params in the page component. In Next.js 15, params is a Promise. You always await it, even though the value is known at build time.

Nested Segments

Most real sites have more than one level of dynamic segments. This site's notebook has both a section and a slug: /notebook/nextjs/some-article. That's two dynamic segments, and each level needs its own generateStaticParams.

// app/notebook/[section]/page.tsx
export function generateStaticParams() {
  return getAllSections().map((s) => ({ section: s.slug }))
}

// app/notebook/[section]/[slug]/page.tsx
export function generateStaticParams() {
  return getAllNotes().map((n) => ({
    section: n.section,
    slug: n.slug,
  }))
}

The section-level function returns every section. The slug-level function returns every (section, slug) pair. Next.js combines them and renders a page for each combination.

You don't have to return the full combination from the deeper function. If you only return { slug: n.slug } at the slug level, Next.js inherits the parent segment from the context already established by the section-level function. But returning the full pair is explicit and easier to follow, so that's the pattern I use.

Catch-All Routes

Catch-all routes, written as [...slug], match any number of path segments. The param is an array rather than a string. generateStaticParams returns arrays too:

// app/docs/[...slug]/page.tsx

export function generateStaticParams() {
  return [
    { slug: ['guide'] },
    { slug: ['guide', 'installation'] },
    { slug: ['guide', 'configuration'] },
    { slug: ['api', 'reference'] },
  ]
}

export default async function Page({
  params,
}: {
  params: Promise<{ slug: string[] }>
}) {
  const { slug } = await params
  const path = slug.join('/')
  // fetch content for this path
}

The build renders out/docs/guide/index.html, out/docs/guide/installation.html, and so on. Each entry in the returned array maps to one output file.

Optional catch-all routes ([[...slug]]) work the same way but also match the root of that segment (the path with nothing after it). You'd include { slug: [] } in the array to render the root.

Blocking Unknown Paths

By default, if someone requests a path that wasn't in generateStaticParams, Next.js tries to render it at runtime. In a static export there is no runtime, so the path simply doesn't exist and the CDN serves a 404. Setting dynamicParams = false makes this behaviour explicit and enforced at build time:

export const dynamicParams = false

export function generateStaticParams() {
  return getAllNotes().map((n) => ({
    section: n.section,
    slug: n.slug,
  }))
}

With dynamicParams = false, any path not returned from generateStaticParams is treated as a 404. In a static export context, this is almost always what you want. It's also self-documenting: the function is the authoritative list of what exists.

Build-Time Implications

generateStaticParams runs at build time, and it can be async. You can read the filesystem, call a database, or fetch from an API:

export async function generateStaticParams() {
  const notes = await getAllNotes() // reads markdown files from content/
  return notes.map((n) => ({
    section: n.section,
    slug: n.slug,
  }))
}

For this site, getAllNotes reads from the content/ directory. That's build-time-safe: the files are on disk, the build can read them. What you can't use are things that only exist at runtime, like a session cookie or a live database that isn't available in CI.

The build renders every returned path, so the size of the array directly affects build time. A site with hundreds of articles takes longer to build than one with ten. That's expected and usually fine. If build time becomes a real problem, look at whether you can filter the list down or parallelize your data fetching inside generateStaticParams.

Adding a new article is a build input change: the markdown file goes in, the build reruns, and a new HTML file appears in out/. No deployment config changes, no route registration. The content is the source of truth.

← Older
Image Optimisation in Next.js
Newer →
Fetching Data in Server Components

Newsletter

A weekly newsletter on React, Next.js, AI-assisted development, and engineering. No spam, unsubscribe any time.