A statically exported Next.js site is just HTML, CSS, JavaScript and assets in a folder. You can put that folder on any static host — Cloudflare Pages, GitHub Pages, S3, Netlify, an Nginx box — with no Node.js server to run, patch or pay for. This site is built exactly that way.
This guide covers how static export works in the App Router, what you give up, and how to work around each limitation.
Turning it on
Since Next.js 14, next export is gone; static export is a config option:
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'export',
// Optional: /about → /about/index.html instead of /about.html
trailingSlash: true,
images: { unoptimized: true },
}
export default nextConfignext build now writes the site to out/. Preview it with any static server:
npx serve outWhat works
More than people expect:
- Server Components. They run once, at build time. Fetch data, read files, call APIs — the result is baked into HTML.
- Client Components, hooks, context and every client-side library.
- Dynamic routes, as long as you list every path with
generateStaticParams. - Route Handlers with
GET, which become static files (handy forfeed.json,sitemap.xml,robots.txt). - The Metadata API: titles, descriptions, Open Graph, canonical URLs.
- Client-side navigation with
<Link>and prefetching.
What doesn't work, and what to do instead
| Feature | Why it fails | Workaround |
|---|---|---|
| Default image optimization | Needs a server | images.unoptimized: true, pre-sized images, or a custom loader (Cloudinary, imgix, Cloudflare Images) |
Dynamic routes without generateStaticParams |
Pages must be known at build time | List all params; set dynamicParams = false |
cookies(), headers(), request-time data |
No request at runtime | Read them on the client, or move logic to an API |
| Server Actions | Need a server | POST to a serverless function (Workers, Lambda) |
| Middleware | Needs a server | Host-level rules: _redirects, _headers, edge functions |
redirects, rewrites, headers in config |
Applied by the Next server | Same: configure them at the host |
ISR / revalidate |
No server to regenerate | Rebuild on content change (a Git push or a webhook) |
| Built-in i18n routing | Pages Router feature | Locale segments: app/[lang]/... with generateStaticParams |
Dynamic routes, done right
Every dynamic segment needs a list of its values at build time:
import { getAllPosts, getPost } from '@/lib/blog'
import { notFound } from 'next/navigation'
export const dynamicParams = false // unknown slugs are a 404, not a runtime render
export async function generateStaticParams() {
const posts = await getAllPosts()
return posts.map((post) => ({ slug: post.slug }))
}
export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
const post = await getPost(slug)
if (!post) notFound()
return <article dangerouslySetInnerHTML={{ __html: post.html }} />
}Note that in Next.js 15 params is a Promise — await it.
One gotcha: if generateStaticParams returns an empty array, the build fails, because the route would produce no pages. If a collection can be empty (say, a new blog), return a placeholder param and let it render a 404.
Images
You have three reasonable options:
unoptimized: trueand export images at the right size yourself (WebP or AVIF, 1–2× the displayed width). Simple and fast if you're disciplined.- A custom loader pointing at an image CDN, which returns resized variants on demand:
export default function loader({ src, width, quality }) {
return `https://images.example.com/${src}?w=${width}&q=${quality || 75}`
}const nextConfig = {
output: 'export',
images: { loader: 'custom', loaderFile: './image-loader.js' },
}- Plain
<img>withsrcset,loading="lazy"and explicitwidth/heightto avoid layout shift.
Forms and anything server-side
A static site can still have forms, search and logins — the server part just lives elsewhere. The usual pattern is a small serverless function that the page calls with fetch. I wrote up the one this site uses in a serverless contact form with Cloudflare Workers and D1.
Redirects and headers
Configure them at the host. On Cloudflare Pages and Netlify, a _redirects file in public/ is copied into out/:
/old-page /new-page 301
/blog/* /journal/:splat 301And _headers for caching and security headers:
/_next/static/*
Cache-Control: public, max-age=31536000, immutableUpdating content without a server
Because there's no ISR, content changes need a rebuild. That sounds worse than it is: connect the host to your Git repository and every push builds and deploys in a minute or two. For content that lives in a CMS, point the CMS's webhook at the host's deploy hook. For a blog, keeping posts as Markdown in the repository is the simplest option of all — see a Markdown blog in Next.js without a CMS.
When static export is the wrong choice
- Pages that depend on who is logged in, rendered on the server.
- Thousands of pages that change every few minutes (build times grow with page count).
- Anything that genuinely needs request-time rendering, like personalised pricing.
For marketing sites, portfolios, documentation, blogs and landing pages, static export is usually the fastest, cheapest and most robust option there is.
Checklist
-
output: 'export'innext.config - Every dynamic route has
generateStaticParamsanddynamicParams = false - Images handled (unoptimized, custom loader or
<img>) - No
cookies(),headers(), Server Actions or middleware - Redirects and headers configured at the host
- Deploys triggered by Git pushes or CMS webhooks
-
out/previewed locally before the first deploy
Next step: make sure search engines understand the pages you export — technical SEO for the Next.js App Router.

