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'innext.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.
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.
| Setting | Exported file | /guides/deploying/ | /guides/deploying |
|---|---|---|---|
trailingSlash: true | guides/deploying/index.html | 200 | redirect (host-dependent) |
trailingSlash: false | guides/deploying.html | 404 or redirect | 200 (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.unoptimizedis 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.
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:
| Measure | Server deployment | Static export |
|---|---|---|
| Build time | 96 s | 132 s |
| p95 TTFB | 210 ms | 28 ms (edge cache) |
| Hosting cost | per-request | flat, effectively zero |
| Features lost | — | middleware, ISR, image optimizer |
| Client JS (median page) | 94 KB | 94 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
dynamicParamsat 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
unoptimizedor 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 —generateStaticParamsremains 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.
Related
- Parent: Next.js Static Export for Content Sites — when this target is the right choice.
- Migrating From Gatsby to Next.js Static Export — getting to this target from another React framework.
- Next.js Static Export vs Astro for Marketing Sites — the alternative when the app constraint does not apply.
- Choosing an SSG for API Reference Documentation — generated routes at much larger scale.
- JavaScript Hydration & Partial Rendering — what export does not remove.