Next.js Static Export: The Complete Guide (What Works, What Breaks)
A practical guide to output: 'export' in Next.js: supported features, common breakages, image handling, routing gotchas, and deploying to a static host.
I’ve shipped several production sites using Next.js static export, including a suite of browser based developer tools that runs on plain Apache shared hosting: no Node server, no Vercel, no serverless functions. Just HTML, CSS, and JavaScript sitting in a folder. When it works, it’s the cheapest, fastest, most portable way to deploy a Next.js app. When it doesn’t, you find out at build time with a cryptic error, or worse, at runtime with a broken page.
The problem is that most Next.js documentation and tutorials assume you’re deploying to a platform that runs the full framework. Static export is treated as an afterthought, and the list of features that silently stop working is longer than most people expect. This guide covers what I’ve learned about output: 'export' in Next.js 14 and 15 with the App Router: what it actually does, what survives the export, what breaks, and how to work around the breakages.
When static export is the right call
Static export makes sense when your pages don’t need to change between deploys. Marketing sites, documentation, portfolios, blogs backed by markdown files, and client side apps are all great candidates. If the content is known at build time, or all the dynamic behavior happens in the browser, you lose nothing by exporting.
My strongest use case has been client side tools. When I wrote about how I built 15 browser based dev tools, the entire premise was that everything runs in the user’s browser: no data ever touches a server. For that architecture, a Node runtime on the server is pure overhead. Static export let me deploy the whole site to a cPanel host I was already paying for.
It’s the wrong call when you need per request rendering: personalized pages, server side auth checks, content that updates without a rebuild, or anything involving Server Actions. If you’re fetching from a CMS and want updates to appear without redeploying, you either rebuild on a webhook or you don’t use static export. Be honest about this upfront, because retrofitting server features into an exported site means changing hosts, not changing code.
The payoff when it fits: you can host anywhere (S3, GitHub Pages, Cloudflare Pages, Nginx, Apache), there’s no server to patch or scale, cold starts don’t exist, and your hosting bill rounds to zero. The attack surface is basically your web server serving files.
Setting up a Next.js static export
The configuration is one line, plus two you’ll almost certainly want alongside it:
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'export',
// Emits /about/index.html instead of /about.html.
// Plays nicely with Apache, S3, and most static hosts
trailingSlash: true,
// The default next/image loader needs a server; disable it
images: {
unoptimized: true,
},
};
module.exports = nextConfig;
With this in place, next build does the export. There’s no separate next export command anymore (it was removed in Next.js 14):
npm run build
# Static site is now in ./out
npx serve out # preview locally
The out/ directory contains plain HTML files, a _next/ folder with your hashed JS and CSS bundles, and anything from public/ copied to the root. Every route becomes a real HTML file on disk. That directory is your entire deployment artifact: upload it anywhere that can serve files.
One thing worth internalizing: the exported pages are still fully hydrated React apps. You get prerendered HTML for the initial load and SEO, and then React takes over on the client. Navigation between exported pages uses the normal Next.js client router with prefetching. It feels identical to a Next.js site deployed on a server for the user.
What works out of the box
More survives the export than people assume, especially with the App Router.
Server Components still run at build time. The “server” is just next build, so you can read the filesystem, fetch from APIs, and render the result into static HTML. This is how my markdown based pages work: fs.readFile in a Server Component, rendered once at build.
Client Components work exactly as normal. 'use client' components keep all their interactivity, hooks, state, and browser APIs untouched.
Dynamic routes with generateStaticParams export fine too, as long as you enumerate every slug at build time. A route like app/blog/[slug]/page.js looks like this:
// app/blog/[slug]/page.js
export async function generateStaticParams() {
const posts = await getAllPosts();
return posts.map((post) => ({ slug: post.slug }));
}
export default async function Post({ params }) {
const { slug } = await params; // params is a Promise in Next.js 15
const post = await getPost(slug);
return <article>{post.content}</article>;
}
Static GET route handlers also survive. A route handler that responds to GET without reading request specific data gets executed at build time and its response written to disk. app/sitemap.xml/route.js returning generated XML is the classic use: you get a real sitemap.xml file in out/.
The usual suspects work too: next/link, next/font, CSS Modules, Tailwind, the Metadata API (including generateMetadata), and the sitemap.js and robots.js conventions.
What breaks (and the workarounds)
This is the section I wish someone had handed me before my first export. Here’s every wall I’ve hit, and what to do about each one.
Server Actions
Server Actions need a server to receive the POST, and there isn’t one. The build fails if you use them. The workaround is old fashioned: point your forms at an external endpoint. I’ve used third party form services, a separate tiny API on the same host, and plain fetch calls from Client Components to external APIs. For a contact form on a static site, a hosted form backend is usually the pragmatic answer.
ISR and revalidation
revalidate, revalidatePath, revalidateTag: none of it applies. There’s no server to regenerate pages. Every content update means a rebuild and redeploy. The workaround is a build webhook. My CMS backed projects trigger CI on content publish, and the site is fresh within a couple of minutes. If you need freshness measured in seconds, static export is the wrong tool.
Middleware
middleware.js runs on a server or edge runtime, so it’s out. Auth gating, geo routing, and A/B splits at the edge are all gone. Redirect and rewrite logic has to move into your host’s config (more on .htaccess below), and auth checks move to the client or behind a separate API.
next/image without configuration
This one bites everyone. The default next/image loader optimizes images through a server endpoint (/_next/image), which doesn’t exist in an export. The build errors out and tells you so. Two options:
// Option 1: skip optimization entirely
images: { unoptimized: true }
// Option 2: custom loader pointing at an image CDN
images: {
loader: 'custom',
loaderFile: './lib/image-loader.js',
}
// lib/image-loader.js: example for an image CDN
export default function cloudLoader({ src, width, quality }) {
return `https://cdn.example.com/${src}?w=${width}&q=${quality || 75}`;
}
With unoptimized: true you keep the layout shift protection and lazy loading of next/image, but it serves original files, so you need to optimize them yourself beforehand (I run images through Sharp in a build script and ship WebP). A custom loader with Cloudinary or similar gives you real optimization back at the cost of an external dependency.
Dynamic routes without generateStaticParams
Any [param] route must enumerate its params. There’s no fallback rendering: a slug you didn’t generate is simply a 404 on the host. If your set of pages is unbounded (user profiles, search results), restructure it as a single client rendered page reading query strings via useSearchParams (wrapped in <Suspense>), or accept that it can’t be a distinct URL.
Redirects and headers in next.config
The redirects() and headers() functions in next.config.js are implemented by the Next.js server, so they’re silently ignored in an export. There’s no build error, they just don’t happen. This is nasty because everything appears to work in next dev. Move them to your hosting layer: .htaccess on Apache, _redirects on Netlify, S3 routing rules, and so on.
Draft mode and cookies
draftMode(), cookies(), and headers() from next/headers all read the incoming request. No request means no data, so pages that use them can’t be statically exported. Preview workflows need to live elsewhere. I run next dev locally against draft content instead.
Deploying to a plain Apache/cPanel host
This is where trailingSlash: true earns its keep. Without it, /about exports as about.html, and Apache won’t resolve /about to that file without rewrite rules. With it, you get about/index.html, which every web server since the 1990s serves correctly for /about/. Set it before you launch. Changing it later changes your canonical URLs.
My deploy process is embarrassingly simple:
npm run build
rsync -avz --delete out/ user@host:~/public_html/
Then I drop an .htaccess in the web root to handle caching and redirects:
# Cache immutable Next.js assets aggressively: filenames are hashed
<FilesMatch "\.(js|css|woff2)$">
Header set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch>
# HTML should always revalidate
<FilesMatch "\.html$">
Header set Cache-Control "public, max-age=0, must-revalidate"
</FilesMatch>
# Redirects that used to live in next.config.js
Redirect 301 /old-tools/ /tools/
# Serve the exported 404 page
ErrorDocument 404 /404.html
The immutable cache on _next/static assets is safe because Next.js hashes the content of every filename, so a new deploy produces new URLs. Enable gzip or Brotli via mod_deflate/mod_brotli if the host supports it, and you’ll get response times that embarrass most sites rendered on a server: there’s no render step, just a file read.
One last production tip: static hosting means your pages are done, but discovery isn’t. A fast site with a clean sitemap is only the starting point. I covered the promotion side in getting your GitHub project noticed, and most of it applies to any static site launch.
FAQ
Is Next.js static export good for SEO?
Yes, arguably better than the default setup for content sites. Every page is complete HTML on disk, so crawlers get full content with zero JavaScript execution and no server response variability. TTFB is as low as your host allows because there’s no rendering on request. The Metadata API works at build time, so titles, descriptions, Open Graph tags, and canonical URLs all export correctly. The only SEO caution is the trailing slash decision: pick it once, keep it consistent, and make sure your host doesn’t serve both /page and /page/ as 200s without a canonical.
Can I use API routes with static export?
Only static ones. A GET route handler that doesn’t depend on the incoming request is executed at build time and written to out/ as a static file, which is useful for generated XML, JSON feeds, or manifest files. Anything that needs runtime behavior (POST handlers, reading query params or cookies, returning fresh data per request) won’t export. For real APIs, use a separate backend or serverless functions on another domain and call them from the client.
Static export vs Astro/Gatsby?
If you’re starting a purely content driven site from scratch, Astro will ship less JavaScript by default and its content tooling is more ergonomic. My own blog runs on it. Gatsby occupies similar ground but with a heavier GraphQL layer that I find hard to justify in 2026. Where Next.js static export wins is when your site is really an app: heavy interactivity, lots of shared React components, a team already fluent in Next.js, or a codebase that might need server features later. Flipping output: 'export' off and deploying to a Node host is a one line migration path that Astro and Gatsby can’t offer.
Wrapping up
Next.js static export is a genuinely underrated deployment target. The mental model is simple: everything that can happen at build time works, and everything that needs a running server breaks. Learn the breakage list: Server Actions, ISR, middleware, the default image loader, config level redirects, and draft mode. Design around it from day one rather than discovering it at deploy time.
For the right project, the trade is excellent: any host, near zero cost, no runtime to maintain, and performance that’s hard to beat because there’s nothing left to compute. My statically exported sites have been the most boring ones I operate, and in production, boring is the highest compliment there is.
Get new posts in your inbox
No spam, no fluff: one email when I publish something worth your time. Unsubscribe anytime.
Signup opens soon. Grab the RSS feed meanwhile.
Keep reading
How I Built 15 Privacy Focused Dev Tools That Run in the Browser
Lessons from building Utilio, a suite of browser based developer tools with no sign ups and no server uploads: architecture, trade offs, and results.
How I Actually Use AI Coding Assistants as a Senior Engineer
A senior engineer's real AI coding workflow: where assistants genuinely help, where they waste time, and the habits that make the difference
From Mid Level to Senior Engineer: What Actually Changes
The senior title isn't about harder code. What changed for me: ownership, ambiguity, communication, and multiplying the effect of everyone around me.