Next.js Static Export, Deployment and Production Mistakes
Web DevelopmentLesson 4 of 46 min read
What you'll learn
- Configure
output: "export"and list the features that are unavailable in a static export - Choose between a Node server, a standalone Docker build and a static host
- Handle environment variables correctly, including NEXT_PUBLIC_ values inlined at build time
- Run a pre-launch checklist that catches the most common production failures
On this page
A developer conference needs a microsite for three weeks: a schedule, speaker pages and a venue map. There is no login, no database and no budget for a server that must stay awake. Next.js can pre-render the whole site into plain HTML files and let any static host serve them. That is called a static export. It is a legitimate and cheap choice, but the moment you reach for a feature that needs a server, it silently stops being possible. This lesson is about knowing where that line is, and about the mistakes that cause trouble once a Next.js app is live.
Turning on static export
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "export",
trailingSlash: true, // /schedule/ -> schedule/index.html
images: { unoptimized: true },
// basePath: "/devsummit", // only if the site lives under a sub-path
};
export default nextConfig;With output: "export", running next build writes a folder named out/ containing HTML, JavaScript, CSS and assets. You upload that folder to any static host, or preview it locally with a simple static file server. next start is not used for an export. Your components still run on the server, but only once, at build time, so Server Components are fine: they simply produce HTML that is saved to disk.
A dynamic route needs to say which pages exist, because there is no server to work it out later. generateStaticParams returns the list of parameter values to build.
import { notFound } from "next/navigation";
type Speaker = { slug: string; name: string; talk: string };
const SPEAKERS: Speaker[] = [
{ slug: "ines-okafor", name: "Ines Okafor", talk: "Debugging production at 3 am" },
{ slug: "lukas-brandt", name: "Lukas Brandt", talk: "Small teams, big migrations" },
];
export function generateStaticParams() {
return SPEAKERS.map((s) => ({ slug: s.slug }));
}
type Props = { params: Promise<{ slug: string }> };
export default async function SpeakerPage({ params }: Props) {
const { slug } = await params;
const speaker = SPEAKERS.find((s) => s.slug === slug);
if (!speaker) notFound();
return (
<main>
<h1>{speaker.name}</h1>
<p>Talk: {speaker.talk}</p>
</main>
);
}After the build, out/speakers/ines-okafor/index.html and out/speakers/lukas-brandt/index.html exist, each a finished page. A URL for a speaker who is not in the list has no file, so the host serves its 404 page. Adding a speaker means rebuilding and redeploying, which is the central trade-off of a static site: content changes need a build.
What a static export cannot do
The documentation lists the features that need a running server and are therefore unsupported in an export. The exact list can shift between releases, so treat this table as the general shape and confirm it for your version.
| Feature | In a static export | Why |
|---|---|---|
Dynamic routes without generateStaticParams | Not supported | No server to render unknown values |
cookies(), headers(), per-request searchParams | Not supported | They need a real request |
| Server Actions and mutating route handlers | Not supported | Nothing is listening for POSTs |
| Redirects, rewrites and custom headers in the config | Not applied | Configure them on your host instead |
| Middleware (named proxy in the newest versions) | Not supported | Runs per request on a server |
Incremental static regeneration and revalidate | Not supported | Pages are never rebuilt after export |
| Default image optimisation | Needs unoptimized or a custom loader | The optimiser is a server feature |
| Server Components that fetch at build time | Supported | The data is baked into the HTML |
Choosing where to deploy
| Target | Build output | Use it when |
|---|---|---|
| Managed Next.js platform | Default .next build, handled by the platform | You want every feature, including ISR and middleware, with little operations work |
| Your own Node server | next build then next start | You control a VM or container and need server features |
| Docker image | output: "standalone" copies only the needed files | You deploy containers; see Docker |
| Static host or CDN | output: "export" and the out/ folder | Content sites with no per-request logic |
With output: "standalone", next build produces a minimal folder under .next/standalone that includes a server.js, which keeps container images small. You still need to copy public and .next/static next to it, because they are not included automatically. For automated builds and releases, wire these commands into a pipeline as covered in CI/CD.
Environment variables: the build-time trap
# Server only: never reaches the browser
DATABASE_URL=postgres://app:change-me@db.internal:5432/app
REVALIDATE_TOKEN=generate-a-long-random-string
# Public: inlined into the JavaScript bundle at BUILD time
NEXT_PUBLIC_SITE_URL=https://devsummit.example
NEXT_PUBLIC_FORM_ENDPOINT=https://forms.example.test/submit/abc123Variables whose names start with NEXT_PUBLIC_ are replaced with their literal values when the app is built, then shipped to every visitor. This has two consequences. First, never put a secret in one of them. Second, changing the value on your host after the build changes nothing in the client code; you must rebuild. A Docker image built once and promoted from staging to production will carry the staging values in its browser bundle. Variables without the prefix are read on the server at runtime, except where a page is prerendered at build time, which bakes in whatever they were during the build.
A pre-launch checklist
[ ] npm run build succeeds locally AND in CI with a clean install
[ ] Tested with next build + next start (or the out/ folder), not only next dev
[ ] No secret appears in the browser bundle (search the built files for it)
[ ] NEXT_PUBLIC_* values are correct for THIS environment before building
[ ] metadata: title, description, metadataBase, Open Graph image on key pages
[ ] app/not-found.tsx and app/error.tsx exist and look intentional
[ ] robots.txt and sitemap are served; staging is blocked from indexing
[ ] Images: width/height set, remote hosts allowed in config, or unoptimized for export
[ ] Deep links work on the host (refresh /speakers/ines-okafor/ directly)
[ ] Logs and an error monitor are connectedCommon mistakes
- Adding `output: "export"` to an app that uses a server feature. The build stops with an error naming the unsupported feature, for example a dynamic route that lacks
generateStaticParams, or a message that the default image loader is not compatible with export. - Image optimisation on a static host. Without
images: { unoptimized: true }(or a custom loader) the export build fails; with an unconfigured remote image host you gethostname is not configured under images in your next.config. - Deep links returning 404 on the host. Visiting
/scheduleworks via the in-app Link but fails on refresh because the host looks forschedule.html. UsetrailingSlash: trueso files land atschedule/index.html, or configure the host's routing. - Changing `NEXT_PUBLIC_` values after the build. The old value is still in the JavaScript. Rebuild with the new value.
- Wrong `basePath` or asset prefix on project hosting. A site served under
/devsummit/withoutbasePathshows unstyled pages and 404s for scripts, which you will see in the Network tab. - Shipping a build made with development settings or stale cache. Run a clean build in CI instead of deploying from a laptop.
- No error or not-found pages. Users see the framework's generic screens, and failures disappear because nothing logs them.
How this shows up in real projects and interviews
At work, deployment decisions are rarely about which button to click and mostly about which features you need: if the answer includes auth, per-user pages or on-demand revalidation, static export is out. Interview questions here are practical: "how would you deploy a Next.js app with Docker?", "what is the difference between next build and next export?" (the standalone next export command was replaced by the output: "export" config option in recent versions), and "why are my environment variables undefined in the browser?" The latter is usually a missing NEXT_PUBLIC_ prefix. Pair this lesson with Node.js for server hosting basics.
Try it yourself
Take the conference microsite idea: a home page, a /schedule page and two speaker pages using generateStaticParams. Enable output: "export" with trailingSlash, run next build, and open the out/ folder to find the generated files. Serve it with any static file server, refresh a speaker URL directly, then deliberately add cookies() to one page and read the build error.
Show a hint
Look for out/speakers/<slug>/index.html. The cookies() error appears at build time, which is the point: export mode surfaces server-only features early.
Key takeaways
output: "export"pre-renders the site into anout/folder of static files; it removes everything that needs a request-time server.- Dynamic routes in an export need
generateStaticParams, images needunoptimizedor a custom loader, and redirects and headers move to the host. NEXT_PUBLIC_variables are inlined at build time and visible to everyone; secrets must never use that prefix.- Test with a production build, check deep links on the real host, and keep a written pre-launch checklist.
Test yourself
A trick from this lesson's ride: Export means a folder of files: upload out/ and there is no server to run. Four more tricks like it wait along the route, each followed by a question.
Frequently asked questions
How do I export a Next.js app as static HTML?
Set output: "export" in your Next.js config and run next build. The static files are written to the out/ folder, which you can upload to any static host. Dynamic routes need generateStaticParams, and images need images: { unoptimized: true } or a custom loader.
What does Next.js static export not support?
Anything that needs a server at request time: cookies and headers, Server Actions, middleware, redirects and rewrites in the config, incremental regeneration and the default image optimiser. The exact list varies by version, so confirm it in the documentation for the one you use.
Why are my environment variables undefined in the browser in Next.js?
Only variables prefixed with NEXT_PUBLIC_ are exposed to browser code, and they are replaced at build time. A variable without the prefix stays on the server, and a public variable changed after the build needs a rebuild to take effect. Never put secrets in public variables.