DEV Community

mrnoyy
mrnoyy

Posted on

Next.js Static Export for Content Sites — the Settings That Actually Matter

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 },
};
Enter fullscreen mode Exit fullscreen mode

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 }));
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. A Node script after the build that walks out/ and writes the XML from the actual files on disk.
  2. next-sitemap as a postbuild script.

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)