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, how to trim that cost, where the routing and image 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/. Both routers support the export: the App Router runs your server components once at build time and serializes the result, while the Pages Router runs getStaticProps at build time and drops getServerSideProps entirely. Either way, the build is a compile step that finishes with a directory of files, not a process you keep alive.
The catch is that anything depending on a server is now off the table: API routes, middleware, on-demand Incremental Static Regeneration, getServerSideProps, 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, so the failure is loud rather than a surprise in production.
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, where routing conventions are one scored axis.
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 const dynamicParams = false; // 404 anything not enumerated below
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. The dynamicParams = false line matters in an export: it tells Next that the set of paths is closed, so a request for a slug you never generated resolves to the static 404 rather than to a route the framework would otherwise try to render on demand — which it cannot, because there is no server. 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.
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. You wire it once with @next/mdx and a mdx-components.js file, and then a component in a post hydrates on the client while the surrounding prose stays static HTML. 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.
Routing and Redirect Caveats
A static export changes how routing works, and the changes bite hardest on the features that quietly assume a server. Four of them come up on almost every content-site migration.
Every dynamic segment must be enumerated. A [slug] or catch-all [...path] route generates nothing unless generateStaticParams (App Router) or getStaticPaths (Pages Router) returns its paths. There is no fallback: 'blocking' and no on-demand generation, so a path you forget to list simply does not exist in out/.
redirects and headers in next.config.js are ignored. Both keys need a server to evaluate them per request, and the export has none — Next drops them from the build. Redirects that used to live in config move to your host: a _redirects file on Cloudflare Pages or Netlify, a Redirect rule on S3/CloudFront, or a rewrite rule at your CDN. The same applies to security and cache headers.
# _redirects (Cloudflare Pages / Netlify) — what next.config redirects becomes
/old-post/ /blog/new-post/ 301
/docs/* /guides/:splat 301
Custom error pages become static files. An app/not-found.js or pages/404.js is emitted as 404.html, and most static hosts serve it automatically for unknown paths. Confirm your host actually wires 404.html to the 404 status — a few need it named explicitly.
Link prefetching still works, but only for real files. The client router prefetches the JSON payload of routes it thinks you will visit, which is why an exported site can feel app-like. That is also part of the JavaScript cost the next section measures. If you rely on Next's redirects for canonical URL hygiene, moving that logic to the host is not optional — it is the only place it can run.
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. It breaks down into three parts: the React and React-DOM runtime, the framework's hydration and app-shell code, and the per-route client bundle. 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.
Trimming the JavaScript You Ship
The 82 KB baseline is a floor, not a fixed cost — and the gap between a careless export and a careful one is large. The lever that matters most on the App Router is keeping components as server components. A server component renders to HTML at build time and ships no client JavaScript of its own; only a component marked 'use client' (and everything it imports) lands in the browser bundle. Treat 'use client' as a budget line, not a default.
// Keep the interactive bit small and push 'use client' to the leaf.
// app/blog/[slug]/page.jsx — server component, 0 client JS
import LikeButton from './like-button'; // the only client component
export default async function Post({ params }) {
const post = await getPostBySlug(params.slug);
return (
<article>
<div dangerouslySetInnerHTML={{ __html: post.html }} />
<LikeButton slug={post.slug} />
</article>
);
}
// app/blog/[slug]/like-button.jsx
'use client';
import { useState } from 'react';
export default function LikeButton({ slug }) {
const [liked, setLiked] = useState(false);
return <button onClick={() => setLiked(true)}>{liked ? 'Liked' : 'Like'}</button>;
}
Two more measures help. First, load heavy widgets — a chart, a syntax-highlighted playground, a comment box — with next/dynamic so their bundle is fetched only when the component actually mounts, keeping it out of the first-load total. Second, watch third-party scripts: an analytics tag or embed can dwarf the framework runtime, and the next/script strategy="lazyOnload" option defers it past the interactive milestone. After trimming, re-run the same Network measurement — on our test article, pushing every non-interactive component back to the server and lazy-loading one chart cut first-load JS from 82 KB to 51 KB. That is still above an Astro island, but it turns "wasteful" into "defensible" for an app-adjacent site. The islands philosophy this borrows from is covered in Astro vs Eleventy for Documentation Sites.
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, Speed, and Deploying
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. The out/ directory is worth understanding before you deploy it — one HTML file per route, a matching data payload, shared framework chunks under _next/, and the static error pages the host falls back to:
_next/static/chunks bundles are the React runtime that an equivalent Hugo or Astro page never ships.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.
Deploying out/ is the easy part, because it is just files. Point Cloudflare Pages, Netlify, or an S3-plus-CDN bucket at the directory and you are done — the output directory is out, the build command is next build, and there is nothing to keep running. Add the host-side redirect and header rules that next.config.js can no longer serve (see the routing section above), set a long cache lifetime on the fingerprinted _next/static/ assets, and a short one on the HTML so content updates propagate. A concrete Gatsby-to-Next migration, including the deploy wiring, is walked through in Migrating from Gatsby to Next.js Static Export.
Common Pitfalls
- Leaving a server-only feature in the tree: an API route,
middleware.ts,getServerSideProps, 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. - Expecting config redirects to run: the
redirectsandheaderskeys are dropped in an export. Re-implement them at the host or CDN, or old URLs quietly 404. - 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 and trim it with server components before you accept 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,
getServerSideProps, on-demand ISR, configredirects/headers, the defaultnext/imageloader) are unavailable; plan around them and move redirect logic to the host. - 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 — but keeping components server-side and lazy-loading widgets trimmed that to 51 KB.
- 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. Keeping components as server components and lazy-loading widgets brought ours down to about 51 KB.
How do redirects and custom 404s work in a static export?
The redirects and headers keys in next.config.js are ignored by output: 'export' because there is no server to run them, so you move that logic to your host's configuration (a _redirects file or a CDN rule). A custom 404 comes from app/not-found.js or pages/404.js, which Next emits as a static 404.html the host serves for unknown paths.
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.
- Handling Dynamic Routes in Next.js Static Export — generateStaticParams, catch-alls and trailing slashes.
- 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.