ibit ships as a folder. next build runs, and out drops a tree of HTML, CSS, and JS — no Node process behind it, nothing to keep warm, nothing to page you at 3am. The whole thing is one line of config:
// next.config.ts
const nextConfig: NextConfig = {
output: "export",
images: { unoptimized: true },
};
That one line is a contract, and the interesting part of the contract is what it takes away. The mental model I keep coming back to: anything that needs to know something at request time is gone, because there is no request time. There's only build time and the browser. If a feature lives in the gap between those two — on a server that answers each hit — it's off the table.
What still works (more than you'd guess)
Static export isn't "the SPA tier." Server Components run — they just run during next build instead of per request, like classic static-site generation. A fetch in a server component fires once at build, bakes its result into the HTML, and ships. So you keep the React Server Component model and the data-at-build pattern; you only lose the per-request part.
You also keep client-side everything: 'use client', hooks, useSWR fetching live data from the browser, client routing between prebuilt pages. It behaves like an SPA once loaded, but each route is a real prerendered HTML file underneath. Even Route Handlers survive in a narrow form — GET only, evaluated at build, written out as a static file:
// app/data.json/route.ts → emitted as /data.json at build
export async function GET() {
return Response.json({ name: "Lee" });
}
The catch is in that sentence: it runs at build. The moment a handler reaches for the incoming Request, you're asking for a server, and you can't have one.
The wall
Here's the boundary, straight from the static-exports guide for the version this project pins. These error out rather than degrade:
- Route Handlers that read
Request— andcookies(),headers(), any dynamic function. rewrites,redirects,headersin config, plusproxy(which in Next.js 16 supersedes the old middleware for this) — these are server response logic, so they evaporate.- Incremental Static Regeneration (
revalidate) and Draft Mode — both assume something running between deploys. - Server Actions — mutations need a server to receive them.
- Dynamic Routes with
dynamicParams: true, or any dynamic route missinggenerateStaticParams(). Every path has to be enumerable at build, because every path becomes a literal file. - Intercepting Routes.
next/imagewith the default loader — it optimizes on a server. That's exactly why this config setsimages: { unoptimized: true }. (You can keepnext/imageby wiring a custom loader to something like Cloudinary; you just can't use the built-in one.)
How the wall actually feels
The honest version: you rarely hit these limits head-on. You hit them sideways, six months in, when you reach for a redirect or a quick auth cookie out of habit and the build refuses. The fix is almost always to push the work to one of the two real places — bake it at build, or do it in the client — or to move that one piece to an edge function and stop pretending the whole site is dynamic.
For a blog, the trade is barely a trade. Redirects move to the host's config (Vercel, Netlify, an Nginx try_files block — the guide even ships an example). Search runs client-side over a prebuilt index. There's no server to patch, no cold start, no attack surface that isn't just static files. The constraint that looked like a cage is mostly a forcing function: it keeps you from accidentally depending on a server you didn't want to run.
Static export isn't Next.js with features removed. It's Next.js with the server removed — and once that reframing lands, the list of "unsupported" features stops reading like a list of bugs and starts reading like a definition.