Handling Dynamic Routes in Next.js Static Export

output: 'export' turns a Next.js application into a directory of HTML files. For a content site that is exactly what you want — no server, no runtime cost, deployable anywhere. The friction is that Next's routing was designed with a server available, and under export several conveniences disappear at once: no fallback rendering, no middleware, no on-demand revalidation.

Dynamic routes still work, and they work well, provided you accept that the list of pages must be fully known at build time. This guide covers generateStaticParams, catch-all segments for documentation trees, the trailing-slash trap, and what to do about the routes you cannot enumerate. It is part of Next.js Static Export for Content Sites.

Prerequisites

  • Next.js 14 or newer using the App Router, with output: 'export' in next.config.js.
  • Content whose full set of routes can be enumerated at build time — files on disk, a committed data file, or an API you can query during the build.
  • A host whose trailing-slash behaviour you can check or configure.

Enumerate Every Route

Under export, generateStaticParams is not an optimisation — it is the definition of which pages exist:

// app/guides/[slug]/page.tsx
import { readdir, readFile } from 'node:fs/promises';
import matter from 'gray-matter';

export async function generateStaticParams() {
  const files = await readdir('content/guides');
  return files
    .filter((f) => f.endsWith('.md'))
    .map((f) => ({ slug: f.replace(/\.md$/, '') }));
}

export const dynamicParams = false;   // anything not listed is a build error, not a 404

export default async function GuidePage({ params }: { params: { slug: string } }) {
  const raw = await readFile(`content/guides/${params.slug}.md`, 'utf8');
  const { data, content } = matter(raw);
  return (
    <article>
      <h1>{data.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: await render(content) }} />
    </article>
  );
}

dynamicParams = false is worth setting explicitly. It converts "a link points at a page we never generated" from a silent 404 in production into a build error, which is the failure you want.

What exists under static export A dynamic route with generateStaticParams returning three slugs produces exactly three HTML files. A fourth slug that was not returned produces no file, so the host serves a 404. With a server, that fourth request could have been rendered on demand; under export there is nothing to render it. The params function is the site map generateStaticParams deploying caching previews exported files guides/deploying/index.html guides/caching/index.html guides/previews/index.html served 200 · 200 · 200 from any static host /guides/rollbacks/ — not returned by the params function no file exported → 404, with no server available to render it on demand
With a server, an unlisted slug can be rendered on request. Under export there is no such path — which is why `dynamicParams = false` and a link check are the two things that make this target safe.

Catch-All Segments for Documentation Trees

Documentation is rarely flat. A catch-all segment plus a directory walk produces one page per document at whatever depth the content sits:

// app/docs/[...path]/page.tsx
import { globSync } from 'node:fs';
import path from 'node:path';

export const dynamicParams = false;

export async function generateStaticParams() {
  return globSync('content/docs/**/*.md').map((file) => ({
    path: path.relative('content/docs', file).replace(/\.md$/, '').split(path.sep),
  }));
}

The path param arrives as an array of segments, so content/docs/deploy/edge/workers.md becomes /docs/deploy/edge/workers/. Two practical notes: use path.sep rather than a hard-coded slash so the build works on Windows, and exclude index.md files from the glob if you want a section landing page handled by a separate route.

Get Trailing Slashes Right Once

This is the most common source of "it works locally and 404s in production" with static export. The trailingSlash option decides what the exporter writes:

// next.config.js
module.exports = {
  output: 'export',
  trailingSlash: true,      // writes guides/deploying/index.html
  images: { unoptimized: true },
};

With trailingSlash: true, every route becomes a directory with an index.html — which is what most static hosts serve naturally for a path ending in a slash. With it false, you get guides/deploying.html, and whether /guides/deploying/ resolves depends entirely on the host.

SettingExported file/guides/deploying//guides/deploying
trailingSlash: trueguides/deploying/index.html200redirect (host-dependent)
trailingSlash: falseguides/deploying.html404 or redirect200 (host-dependent)

Pick one, make every internal link match it, and verify against a real deployment rather than next start. A mismatch does not break the site — it turns every internal link into a redirect, which is a measurable delay on a page with forty of them.

What You Give Up

Under export, several Next features are simply unavailable, and it is better to know before choosing the target:

  • Middleware — no request-time code, so no redirects, rewrites, geolocation or auth at the edge from Next itself. Your host's own edge layer can still do all of it.
  • Route handlers — no API routes in the same project; the client calls an external API directly.
  • Image optimisation — the built-in optimizer needs a server, so images.unoptimized is required or a custom loader points at an image CDN.
  • Revalidation — no ISR, no on-demand revalidation. Fresh data means a rebuild or a client-side fetch.
Which Next features survive static export Two columns. Available under export: App Router, dynamic and catch-all routes, React Server Components rendered at build time, client components, and static asset handling. Unavailable: middleware, route handlers, built-in image optimisation, incremental static regeneration and on-demand revalidation. Check this list before choosing the target Works under export App Router and layouts dynamic + catch-all routes server components at build time client components and hydration static assets and fonts everything a content site normally needs Unavailable middleware route handlers / API routes built-in image optimisation ISR and on-demand revalidation server actions move these to the host's edge layer, or rebuild
Nothing in the right-hand column is missing from a static site in general — the host's edge layer provides most of it. What is missing is Next's own implementation of it.
How trailingSlash changes the exported file layout With trailingSlash true, the route slash guides slash deploying exports to guides slash deploying slash index dot html and resolves directly for a slashed URL. With trailingSlash false it exports to guides slash deploying dot html, and a slashed request depends on host normalisation, producing a redirect or a 404. One setting, two very different file trees trailingSlash: true guides/deploying/index.html /guides/deploying/ → 200 every host serves this the same way trailingSlash: false guides/deploying.html /guides/deploying/ → host-dependent redirect on some hosts, 404 on others Whichever you choose, make internal links match it — a mismatch turns every link into a redirect Verify on a real deployment; the local dev server normalises more forgivingly than most hosts
The directory form is the safer default for a content site: it behaves identically across hosts and matches how documentation URLs are usually written.

Routes You Cannot Enumerate

Occasionally a route genuinely cannot be listed at build time: a search results page, a permalink built from a query parameter, a preview of unpublished content. Three answers, in order of preference.

Move the variation into query parameters on one static page. /search/?q=deploy is one exported page that reads the query string on the client. This is almost always the right answer for search.

Render it at the edge. Your host's Worker or function layer can serve a route Next never exported — the two coexist happily, since the export is just files.

Rebuild. If the set of routes changes rarely, a rebuild triggered by the change is simpler than any runtime mechanism. The scheduled-build pattern in Scheduling Content Publication With Cron-Triggered Builds covers this exactly.

Measured Impact

A 1,400-page documentation site migrated from a Next server deployment to static export:

MeasureServer deploymentStatic export
Build time96 s132 s
p95 TTFB210 ms28 ms (edge cache)
Hosting costper-requestflat, effectively zero
Features lostmiddleware, ISR, image optimizer
Client JS (median page)94 KB94 KB (unchanged)

The TTFB row is the point of the exercise: every page is a file at the edge, so there is no origin to wait for. The build-time row is the cost, and it is paid by CI rather than by readers. The client JavaScript is unchanged, which is worth noting — export removes the server, not the hydration, so a heavy component tree stays heavy. That side of the trade-off is covered in JavaScript Hydration & Partial Rendering.

Pitfalls & Rollback

  • Leaving dynamicParams at its default. A missing param becomes a production 404 instead of a build failure.
  • Trailing-slash mismatch. Every internal link becomes a redirect; set it explicitly and verify against a real deploy.
  • Assuming image optimisation still works. It needs a server; set unoptimized or configure a loader pointing at an image CDN.
  • Client-only content. Anything rendered in an effect is absent from the exported HTML, so it is invisible to search engines and lands after hydration.
  • Enumerating from a live API without pinning. A build whose route list comes from a changing API is not reproducible; snapshot the response.
  • Rollback: removing output: 'export' restores the server target, and dynamic routes keep working — generateStaticParams remains valid, it simply becomes an optimisation again rather than a definition.

Conclusion

Static export asks one thing: know every route at build time. Answer that with generateStaticParams over your content directory, use catch-all segments for nested documentation, set dynamicParams = false so gaps fail loudly, and settle the trailing-slash question once against a real deployment. What you lose is Next's server-side features, most of which your host's edge layer already offers; what you gain is a site that is files at the edge. The wider case for this target is in Next.js Static Export for Content Sites.

FAQ

What happens to a dynamic route that generateStaticParams does not return?

Nothing is exported for it, so the host serves a 404. There is no fallback rendering under output: 'export', which means the params function is the complete list of pages your site has — anything missing simply does not exist.

Can I use catch-all routes with static export?

Yes, and they are the natural fit for documentation trees. A catch-all segment plus a generateStaticParams that walks your content directory produces one page per document, with the path segments coming from the file structure.

Why do my exported URLs have a trailing slash sometimes and not others?

Because the trailingSlash config option decides whether the export writes about.html or about/index.html, and the host then normalises differently. Set it explicitly and match it to your host's behaviour, or half your internal links become redirects.

Does middleware work with output export?

No. Middleware, route handlers, image optimisation and on-demand revalidation all require a server. Under export those features are unavailable, which is the main constraint to check before choosing this target.

How do I handle a page whose data changes without a rebuild?

You rebuild, or you fetch it on the client. Static export has no revalidation, so anything that must be current either triggers a deploy or is loaded at runtime from an API the page calls directly.