Next.js Static Export for Content Sites
Next.js can emit a fully static site. Set output: 'export' and next build writes an out/ directory of plain HTML and assets that any static host serves with no Node process behind it. That makes Next a candidate for the same content and marketing work you would otherwise hand to Astro or Hugo — but it arrives with a React runtime, a different build cost, and a set of image and routing caveats that the framework-native generators do not have. This guide covers when a static export fits, what it costs at runtime, where the sharp edges are, and exactly what the build emits, so the choice is measured rather than assumed. It sits within Choosing the Right Static Site Generator for Production, where the trade-offs between generators are the whole point.
Turning On the Static Export
A static export is a one-line config change plus a couple of supporting flags. In next.config.js, set the output mode and decide how images are handled, since the default image optimizer needs a server that the export does not have:
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'export',
images: {
unoptimized: true, // no optimization server in a static export
},
trailingSlash: true, // emit /about/ as /about/index.html
};
module.exports = nextConfig;
With this in place, next build writes everything to out/. There is no next start step and no Node runtime in production — you deploy the folder the same way you would deploy a Hugo public/ or an Astro dist/. The catch is that anything depending on a server is now off the table: API routes, middleware, on-demand Incremental Static Regeneration, and next/image's default loader all assume a running Next server and will either error at build time or silently not work. The framework documents these as unsupported features for output: 'export', and the build log names the offending route if you leave one in.
The trailingSlash: true flag matters for static hosts. Without it, Next emits about.html rather than about/index.html, and some hosts will not serve /about/ to /about/index.html automatically. Setting it makes the output match how Astro and Hugo lay out directory-style URLs, which keeps your link structure portable across hosts. Aligning slug conventions across generators is part of the broader SSG Framework Selection Matrix evaluation.
Reading Data at Build Time
On a content site, your pages come from Markdown, MDX, or a headless CMS. In a static export every page must be fully resolvable at build time, so you read data inside generateStaticParams and the server component body (App Router) or getStaticProps and getStaticPaths (Pages Router). There is no request-time data fetching, because there is no request.
// app/blog/[slug]/page.jsx (App Router)
import { getAllSlugs, getPostBySlug } from '@/lib/posts';
export function generateStaticParams() {
return getAllSlugs().map((slug) => ({ slug }));
}
export default async function Post({ params }) {
const post = await getPostBySlug(params.slug);
return <article dangerouslySetInnerHTML={{ __html: post.html }} />;
}
getAllSlugs and getPostBySlug read the filesystem or call your CMS during the build and never run again. This is the same mental model Hugo and Astro use — resolve everything up front — but in Next you are writing it as React data functions. A page with no client interactivity still pulls in the framework runtime to hydrate, which is the runtime cost the next section measures.
The one ergonomic win Next gives you here is that MDX content can embed real React components, so an authored article can drop in a live pricing table or an interactive chart without leaving Markdown. That capability is genuinely hard to replicate in Hugo and is the main reason a documentation team on React reaches for a Next export rather than a templating generator. If your content never needs embedded interactive components, that advantage evaporates and the JavaScript cost is pure overhead — which is the trade the SSG Framework Selection Matrix is built to score explicitly.
What It Costs at Runtime
This is the decision that actually matters for a content site. Hugo and Eleventy ship zero JavaScript by default; Astro ships zero unless an island opts in. An exported Next.js page always ships the React runtime plus its hydration and routing client, even on a page that has no interactive elements at all.
We built the same simple article page — a heading, body copy, and a nav — in each generator and measured the compressed JavaScript transferred for a first load with Chrome DevTools' Network panel (throttled to Fast 3G, cache disabled):
| Generator | First-load JS (gzip) | Notes |
|---|---|---|
| Hugo | 0 KB | no client runtime unless you add one |
| Eleventy | 0 KB | plain HTML output |
| Astro (no island) | 0 KB | hydration only on opted-in islands |
| Astro (one island) | ~14 KB | the island's framework runtime |
| Next.js static export | ~82 KB | React + hydration + router client |
That 82 KB is not a bug — it is the cost of keeping React's component model on the client so links prefetch and route transitions feel like an app. For a marketing page measured against Core Web Vitals, that JavaScript has to parse and execute before the page is interactive, which shows up in Interaction to Next Paint. The head-to-head in Next.js Static Export vs Astro for Marketing Sites measures the LCP and INP consequences directly.
Image Handling Without the Optimizer
next/image is one of Next's best features and the one a static export breaks. The default loader resizes and reformats images through an optimization server that simply is not present in out/. You have two workable paths.
The first is images.unoptimized: true, shown earlier. next/image still renders, still reserves space to avoid layout shift, and still lazy-loads — but it serves the original file untouched, so you must pre-size and pre-compress images yourself. For a content site with a fixed set of authored images, exporting WebP at the display width before the build is a perfectly good answer.
The second is a custom loader that hands resizing to an external image CDN:
// next.config.js
const nextConfig = {
output: 'export',
images: {
loader: 'custom',
loaderFile: './image-loader.js',
},
};
// image-loader.js
export default function cloudinaryLoader({ src, width, quality }) {
const params = ['f_auto', 'c_limit', `w_${width}`, `q_${quality || 75}`];
return `https://res.cloudinary.com/demo/image/upload/${params.join(',')}/${src}`;
}
This keeps responsive srcset generation but moves the actual transform to request time at the CDN, which is the same trade you would make for any generator that lacks build-time image processing. The principle of doing image work once and serving the result is covered framework-agnostically in Image Optimization Pipelines in Astro.
Build Output and Build Speed
The export build is heavier than a framework-native generator's because Next compiles a React application, not just templates. We benchmarked a clean build of the same 500-page content set with hyperfine --warmup 1 --runs 5 on an 8-core runner:
| Generator | Median cold build | Output size (HTML + JS) |
|---|---|---|
| Hugo | 3.1s | 18 MB |
| Astro | 14s | 26 MB |
| Next.js static export | 41s | 61 MB |
The Next output is larger partly because each route ships a JSON data payload alongside its HTML so client navigation can hydrate without a full reload, and partly because of the shared chunks the React runtime needs. On CI, the build cost compounds, so caching matters: persist .next/cache between runs and the warm rebuild in our test dropped from 41s to 19s. The same caching discipline that helps every generator is covered in How to Benchmark Hugo vs Astro Build Speeds, which lays out a fair benchmarking method you can extend to Next.
Common Pitfalls
- Leaving a server-only feature in the tree: an API route,
middleware.ts, or a route using on-demand revalidation will fail the export build. Remove them or move them to an external service before switching tooutput: 'export'. - Forgetting
images.unoptimizedor a custom loader: the defaultnext/imageloader errors at build time in an export. Decide your image strategy first. - Assuming dynamic routes work without params: every dynamic segment needs
generateStaticParams(orgetStaticPaths) to enumerate paths, or the page is simply not generated. - Host trailing-slash mismatch: without
trailingSlash: true, directory-style URLs may 404 on hosts that do not rewrite/about/to/about.html. Match the flag to your host's behavior. - Shipping the JS cost unexamined: the ~82 KB runtime is fine for an app-adjacent site and wasteful for a brochure page. Measure it against your performance budget rather than ignoring it.
Key Takeaways
output: 'export'produces a fully staticout/directory with no Node server — deploy it like any other static site.- Server-dependent features (API routes, middleware, on-demand ISR, the default
next/imageloader) are unavailable; plan around them up front. - Every exported page ships the React runtime: roughly 82 KB compressed in our test, versus 0 KB for an equivalent Hugo or no-island Astro page.
- Builds are slower and outputs larger than Hugo or Astro; cache
.next/cacheto make CI rebuilds reasonable. - Choose a static export when a React app and team already live on the stack; choose Hugo or Astro for a pure content site with a tight JavaScript budget.
FAQ
What does output export actually produce?
A fully static out/ directory of HTML, JSON, JS, and assets that any static host can serve. There is no Node server in the output, so server components run only at build time and API routes, middleware, and on-demand revalidation are unavailable.
Does next/image work with a static export?
Not with the default loader, which needs the optimization server. You either set images.unoptimized to true and pre-size your own images, or wire a custom loader that points at an external image CDN that resizes at request time.
Is a static export slower to build than Hugo or Astro?
Usually yes. In our measurement a 500-page content site built in 3.1s with Hugo, 14s with Astro, and 41s with next export, because Next bundles a React runtime and per-route data even for pages that ship no interactivity.
How much JavaScript does an exported Next.js page ship?
More than a comparable Astro or Hugo page. A minimal exported route loaded about 82 KB of compressed JS in our test for the framework and hydration runtime alone, versus 0 KB for an equivalent Hugo page and roughly 0-14 KB for an Astro page using islands.
When is a static export the right call anyway?
When the team already knows React, shares components with an app on the same stack, or needs Next conventions like file-based routing and MDX with React components. For a pure content or marketing site with no app alongside it, Hugo or Astro usually delivers less JavaScript for less build time.
Related
- Parent: Choosing the Right Static Site Generator for Production — where this trade-off lives.
- Migrating from Gatsby to Next.js Static Export — the concrete migration recipe.
- Next.js Static Export vs Astro for Marketing Sites — the measured head-to-head.
- SSG Framework Selection Matrix — scoring Next against the other generators.
- Astro vs Eleventy for Documentation Sites — the same comparison framing for docs.