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.

When Next.js static export fits a content site, and when it does not A decision split: a shared React app or team pushes toward static export, while a JavaScript budget near zero or a fast-build requirement pushes toward Hugo or Astro. The export build emits an out directory of HTML, JSON data, and a React runtime. output: 'export' — does it fit this content site? next build output: 'export' Fits when React app shares the stack team already knows Next MDX with React components JS runtime is acceptable Reach for Hugo / Astro when JS budget near zero build speed is critical no React app alongside pure content / marketing
A shared React stack and team familiarity pull toward a static export; a near-zero JavaScript budget or a strict build-time target pull toward Hugo or Astro.

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):

GeneratorFirst-load JS (gzip)Notes
Hugo0 KBno client runtime unless you add one
Eleventy0 KBplain HTML output
Astro (no island)0 KBhydration only on opted-in islands
Astro (one island)~14 KBthe island's framework runtime
Next.js static export~82 KBReact + 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:

GeneratorMedian cold buildOutput size (HTML + JS)
Hugo3.1s18 MB
Astro14s26 MB
Next.js static export41s61 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:

What next build writes to the out directory A file tree of the exported out folder. Each route emits an HTML file (index.html for the home page, about/index.html, blog/[slug]/index.html) plus a static 404.html fallback. Under _next/data a matching JSON payload sits beside each route so client navigation can hydrate without a full reload, and under _next/static/chunks the shared React runtime, hydration app shell, and per-route bundles are the JavaScript that makes an exported page ship roughly 82 kilobytes even when it has no interactivity. Inside out/ after next build out/ ├─ index.html ├─ about/ │ └─ index.html ├─ blog/ │ └─ [slug]/ │ └─ index.html ├─ 404.html └─ _next/ ├─ data/ │ ├─ index.json │ ├─ about.json │ └─ blog/[slug].json └─ static/chunks/ ├─ framework-*.js ├─ main-*.js └─ [route]-*.js HTML page HTML page HTML page HTML · static 404 fallback route data payload route data payload route data payload React + React-DOM hydration / app shell per-route bundle HTML — one pre-rendered page per route; ships no framework JS on its own. Data payload — per-route JSON so client navigation hydrates without a full reload. React runtime — shared chunks that make an exported page ~82 KB even when static.
Every route emits an HTML file and a matching JSON data payload; the shared _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 to output: 'export'.
  • Forgetting images.unoptimized or a custom loader: the default next/image loader errors at build time in an export. Decide your image strategy first.
  • Assuming dynamic routes work without params: every dynamic segment needs generateStaticParams (or getStaticPaths) to enumerate paths, or the page is simply not generated.
  • Expecting config redirects to run: the redirects and headers keys 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.
When the export target is the right call Two panels. Export fits when the site is content, the team already runs Next, and no request-time features are needed. It does not fit when middleware, incremental regeneration, route handlers or on-demand personalisation are load-bearing. When the export target is the right call Export fits the site is documents, not an app the team already runs Next elsewhere a shared design system exists no request-time logic is needed hosting should be files at the edge Export does not fit middleware does redirects or auth ISR keeps pages fresh route handlers serve an API personalisation happens per request the image optimizer is load-bearing Decide this before adopting the target; retrofitting a server back in is a larger change than starting with one.
Everything in the right column has an equivalent at the host's edge layer — but it is no longer Next providing it.

Key Takeaways

  • output: 'export' produces a fully static out/ directory with no Node server — deploy it like any other static site.
  • Server-dependent features (API routes, middleware, getServerSideProps, on-demand ISR, config redirects/headers, the default next/image loader) 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/cache to 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.