I run a set of content-heavy sites on Next.js with output: 'export'. No server, no serverless functions, just HTML on a CDN. It is fast and cheap, but the defaults are tuned for the server runtime and a few of them will quietly cost you traffic.
The config I end up with every time
/** @type {import('next').NextConfig} */
module.exports = {
output: 'export',
trailingSlash: true,
images: { unoptimized: true },
};
Three lines, and each one solves a real problem.
trailingSlash
With output: 'export', a route like /reviews/example is written to disk. Without trailingSlash, you get reviews/example.html. With it, you get reviews/example/index.html.
Why that matters: most static hosts serve /reviews/example/ from the directory form without a redirect, but serve /reviews/example by redirecting first. If half your internal links point one way and your sitemap points the other, every crawl spends requests on 301s before reaching content. Pick one form, set it in config, and make your sitemap match exactly.
Also check what your host does by default. Some normalize trailing slashes, some do not, and finding out after launch means a wave of redirect chains.
images: unoptimized
The default Image Optimization API needs a running server. With a static export it either fails the build or, on some hosts, silently falls back to an optimizer you did not intend to use.
unoptimized: true makes next/image emit a plain <img> with the width, height, and lazy loading attributes intact. You still get the layout-shift protection, which is the part that affects Core Web Vitals. You lose automatic resizing, so generate the sizes you need at build time — or accept the original file size, but then actually check what you are shipping. A 2MB hero image will undo everything else on this list.
Dynamic routes need generateStaticParams
Every dynamic segment must enumerate its paths at build time:
export async function generateStaticParams() {
const posts = await getAllPosts();
return posts.map((post) => ({ slug: post.slug }));
}
Miss one and that page simply is not in your output. It is not a 404 you will see in dev — dev renders on demand and looks perfect. You find out from the live site or from a crawl.
I now diff the built out/ directory against my content source as a build step. A page count mismatch fails the build. That check has caught two silent drops for me.
The sitemap problem
Static export does not generate a sitemap. Plenty of tutorials hand you a sitemap.xml route handler, which works on the server runtime and does nothing here.
Two options that do work:
- A Node script after the build that walks
out/and writes the XML from the actual files on disk. -
next-sitemapas apostbuildscript.
I prefer the first, because it reflects what was really exported rather than what the config says should exist. If generateStaticParams dropped a page, a config-driven sitemap still lists it and you send crawlers to a 404. A disk-driven sitemap cannot lie to you.
Same for robots.txt — put it in public/ and it gets copied verbatim.
What you give up
Be honest about this before choosing static export:
- No middleware, no rewrites, no server-side redirects. Redirects move to your host's config, and every host spells it differently.
- No ISR. Any content change means a rebuild and redeploy.
- No route handlers. Forms need a third-party endpoint or an external function.
For a marketing site, docs, or a review site that changes a few times a week, none of that hurts. For anything with per-user content, you want the server runtime.
Build times
Static export builds every page every time. At a few hundred pages that is fine. Past a couple of thousand it becomes the slow part of your day.
What helped most was caching the data-fetching layer. If your pages come from an API or a database, fetch once into a local JSON cache and let the build read from that. The rendering is rarely the bottleneck — the network round trips are.
None of this is exotic. It is just that the export path and the server path share a framework and not a set of assumptions, and the docs cover both without always telling you which one you are reading about.
Top comments (0)