
Next.js Deploying
Every Next.js tutorial ends the same way: npm run dev, the app works on localhost:3000, and the reader is left to figure out the rest on their own. That "rest" turns out to be the part that actually matters in production — where the app runs, how it caches, what happens when you scale to more than one instance, and what breaks silently if you get any of that wrong.
Next.js is unusual among frameworks in that it doesn't lock you into one hosting story. It can run as a long-lived Node.js process, inside a Docker container, as a folder of static files with no server at all, or through a growing ecosystem of platform-specific adapters. Each option supports a different subset of the framework's features, and picking the wrong one for your app means discovering — often in production — that a feature you were relying on simply doesn't work. This article walks through all four paths, then goes deeper into the operational details Next.js's own deployment page only links out to: caching behavior, multi-instance coordination, and the reverse-proxy configuration you need to get streaming working correctly.
The Four Deployment Paths at a Glance
Before diving into any one option, it helps to see them side by side, because the right choice depends entirely on what your app actually needs:
| Deployment Option | Feature Support | Best For |
|---|---|---|
| Node.js server | All | Full control, custom infrastructure, apps using every App Router feature |
| Docker container | All | Kubernetes, container orchestrators, reproducible environments |
| Static export | Limited | Marketing sites, docs, content that doesn't need a server per request |
| Adapters | Varies | Platform-specific hosting (Vercel, Bun, Cloudflare, Netlify, and others) |
"All" feature support means Server Components, Server Actions, Route Handlers, ISR, image optimization, and the proxy layer all work exactly as documented. "Limited" means you've opted out of anything that requires a live server to handle a request — which, as you'll see below, is a bigger trade-off than it first appears.
Deploying to a Node.js Server
This is the baseline option, and it's the one that supports every feature without exception. Your package.json needs the standard three scripts:
{
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
}
}
npm run build compiles your application — this is the step that runs Server Component rendering ahead of time where possible, generates the production JavaScript bundles, and produces the routing manifest the server uses at runtime. npm run start then boots a Node.js process that listens for requests and serves everything: static assets, server-rendered pages, Route Handlers, and Server Actions.
What the docs don't spell out clearly enough is that this Node.js process is a real, persistent server — not a request-scoped function. It holds in-memory state (like the default filesystem-backed cache) between requests, which is exactly why single-instance self-hosting "just works" with zero configuration, and why running several instances of it introduces the coordination problems covered later in this article.
If next start genuinely doesn't fit your infrastructure — you need a WebSocket server bolted onto the same process, for example, or custom request routing logic that can't be expressed through next.config.js — you can eject to a custom server. This is explicitly a last resort in the docs, and for good reason: a custom server opts you out of some of the automatic optimizations Next.js applies to its own request handling, and it's more code you now own and maintain. Reach for rewrites, redirects, or Route Handlers first.
Deploying with Docker
Docker deployment gets you the same full feature set as a bare Node.js server, wrapped in a container image you can hand to Kubernetes, ECS, or any other container orchestrator. The appeal here isn't extra Next.js functionality — it's reproducibility. The same image runs identically on your laptop, in CI, and in production, which sidesteps an entire category of "works on my machine" bugs.
The detail worth calling out is output: "standalone" in next.config.js. Without it, a naive Docker build copies your entire node_modules folder into the image — often hundreds of megabytes of dependencies you don't need at runtime. With it enabled, next build traces exactly which files your application needs to run and outputs a minimal, self-contained folder:
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "standalone",
};
module.exports = nextConfig;
A minimal production Dockerfile built around this looks roughly like:
FROM node:20-alpine AS base
FROM base AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
EXPOSE 3000
CMD ["node", "server.js"]
Notice that the final stage never runs npm install at all — the standalone output already bundled a minimal node_modules for you. This multi-stage pattern is what keeps the shipped image small: dependencies and build tooling live only in intermediate stages that get discarded.
If you'd rather ship a fully static site inside a container instead of a running Node.js server, output: "export" (covered next) pairs with a lightweight static file server like nginx for an even smaller image — you're trading server-side features for a container that's essentially just HTML, CSS, and JS.
Static Export
Static export takes the opposite approach entirely: instead of running a server, next build with output: "export" produces a folder of plain HTML, CSS, and JavaScript files that any static file host can serve — S3, Nginx, Apache, GitHub Pages, whatever you already have lying around.
This is genuinely the right call for content that doesn't change per-request: marketing pages, documentation sites, blogs where every reader sees the same HTML. But "limited feature support" understates how much you give up. Anything that requires a live server to evaluate per-request is off the table: Server Actions, Route Handlers that read the incoming request, ISR-style revalidation, the proxy layer, and dynamic rendering based on cookies or headers. Image Optimization still works, but only if you configure a custom image loader — by default next/image optimizes images at request time on a server, and there's no server here to do that.
The practical rule of thumb: if you find yourself reaching for next.config.js rewrites that inspect headers or cookies, or you need a Server Action to mutate data, static export isn't the right target for that route. You can mix static and dynamic within one project by starting as a static export and later adopting server features once you actually need them — but you can't have both in the same build output.
Deployment Adapters
The newest piece of the deployment story is the Adapter API, exposed through adapterPath in next.config.js. Adapters let a hosting platform hook into the build process itself — customizing how Next.js is built and packaged for that platform's specific infrastructure, rather than the platform having to reverse-engineer Next.js's output.
Next.js draws a distinction between verified adapters — open source, run through the full Next.js compatibility test suite, and maintained under the Next.js GitHub organization (currently Vercel and Bun) — and platforms that ship their own unverified integrations (Cloudflare, Netlify, and others, which predate the public Adapter API and are working on verified versions). The practical implication: a verified adapter gives you a documented compatibility guarantee tested against Next.js releases. An unverified platform integration might work great, but feature parity and edge cases are between you and that platform's own documentation — not something the Next.js team has signed off on.
If you're choosing a platform specifically because it advertises "Next.js support," it's worth checking which category it falls into before you build anything that depends on a less common feature like Partial Prerendering or after().
What Self-Hosting Actually Involves Once You're Live
Here's where the official deployment page stops and the real operational work begins. Choosing Node.js or Docker gets your app running — it doesn't mean you're done.
Put a reverse proxy in front of it. Next.js recommends nginx or similar sitting in front of your next start process rather than exposing it directly to the internet. This isn't Next.js-specific caution — it's the same reason you wouldn't expose any raw application server directly. A reverse proxy absorbs malformed requests, slow-connection attacks, oversized payloads, and rate limiting, so your Next.js process spends its resources rendering instead of defending itself.
Image Optimization works out of the box — with one platform caveat. next/image optimizes images automatically when self-hosted via next start, no configuration required. The one gotcha worth knowing about ahead of time: on glibc-based Linux distributions, the underlying image library can balloon memory usage unless you tune its allocator settings. If you've ever seen a self-hosted Next.js container's memory climb steadily under image-heavy traffic, this is usually why.
The proxy layer doesn't run under static export. Next.js's request-level proxy (the successor to what used to be middleware) needs an actual incoming request to inspect, so it has nothing to attach to in a static export. If your logic can be expressed as a check inside a Server Component layout instead — reading headers or cookies and calling redirect() — that's the documented workaround. Header/cookie/query-based rewrites and redirects can also be expressed directly in next.config.js without needing the proxy layer at all.
Environment variables split into build-time and runtime, and the distinction matters. By default, environment variables are server-only. Prefixing one with NEXT_PUBLIC_ exposes it to the browser — but that also means it gets inlined into the JavaScript bundle at build time, which is a one-way door. If you're building one Docker image and promoting it across dev, staging, and production, you cannot bake environment-specific NEXT_PUBLIC_ values into that image at build time and expect them to differ per environment; they're frozen into the bundle already. For genuinely runtime-evaluated values, read process.env inside a component that has opted into dynamic rendering:
import { connection } from "next/server";
export default async function Component() {
await connection();
// Reading this here means it's evaluated at request time,
// not baked in during the build.
const value = process.env.MY_VALUE;
// ...
}
That connection() call matters more than it looks — without it, Next.js may still try to statically render the component at build time, capturing whatever value was present in the build environment rather than the runtime one.
Caching Behavior You Need to Understand Before You Scale
This is the part that catches people off guard, because it works invisibly right up until it doesn't. A single self-hosted next start instance with a persistent local disk caches correctly with zero configuration — static pages, ISR output, and build artifacts all live in a filesystem-backed cache that Next.js manages for you.
Next.js sets three different Cache-Control behaviors automatically, and understanding which one applies to which kind of content explains a lot of confusing CDN behavior down the line:
- Truly immutable assets (anything with a content hash in its filename, like statically imported images) get
public, max-age=31536000, immutable— cache forever, non-negotiable. - ISR pages get
s-maxage=<your revalidate value>, stale-while-revalidate— but only if the CDN or reverse proxy in front of your app actually respects that header and understands your cache key variability. A misconfigured CDN in front of an ISR route is a classic way to serve either permanently stale or never-cached content without realizing it. - Dynamically rendered pages — anything reading cookies, headers, or search params in a way that opts out of static rendering — get
private, no-cache, no-store, max-age=0, must-revalidate, specifically so user-specific content never gets cached somewhere it shouldn't be.
The moment you stop running a single instance — multiple containers behind a load balancer, ephemeral serverless-style compute, anything where "local disk" isn't guaranteed to persist or be shared — that default in-memory/on-disk cache stops being a single source of truth. Each instance has its own copy, and none of them know about the others' writes.
The fix is a custom cache handler, wired up in next.config.js:
// next.config.js
module.exports = {
cacheHandler: require.resolve("./cache-handler.js"),
cacheMaxMemorySize: 0, // disable the default in-memory cache
};
// cache-handler.js
const cache = new Map();
module.exports = class CacheHandler {
constructor(options) {
this.options = options;
}
async get(key) {
// Swap this Map for Redis, S3, or any durable store
return cache.get(key);
}
async set(key, data, ctx) {
cache.set(key, {
value: data,
lastModified: Date.now(),
tags: ctx.tags,
});
}
async revalidateTag(tags) {
tags = [tags].flat();
for (const [key, value] of cache) {
if (value.tags.some((tag) => tags.includes(tag))) {
cache.delete(key);
}
}
}
};
The in-memory Map above is a starting point to show the shape of the interface, not something to actually ship — in production this backs onto Redis, S3, or similar durable storage so every instance reads and writes the same cache. It's worth knowing that revalidatePath isn't a separate mechanism under the hood; it's a convenience wrapper that calls revalidateTag with a special tag scoped to that page, so a custom handler that correctly implements tag-based invalidation gets path-based revalidation for free.
Multi-Instance and Rolling Deployment Pitfalls
Beyond the cache, running more than one instance of your app introduces three specific failure modes that are easy to miss until they show up as confusing production errors.
Server Function encryption key mismatches. Next.js encrypts the closure variables of Server Actions before sending them to the client, and by default generates a fresh encryption key on every build. Run two instances built independently — or rebuild without redeploying every instance in lockstep — and you'll see "Failed to find Server Action" errors, because one instance can't decrypt a payload encrypted by another. Fix this by pinning a consistent key across your build pipeline:
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY=your-generated-key next build
The key needs to be a base64-encoded AES key (16, 24, or 32 bytes) — Next.js defaults to 32-byte keys if you don't provide your own.
Version skew during rolling deployments. When you deploy new instances gradually while old ones are still serving traffic, a client can end up holding JavaScript, prefetched navigation data, or a Server Action reference from a version of your app that no longer matches what a given server instance is running. Setting deploymentId gives Next.js a way to detect this: static assets get tagged with a ?dpl= query parameter, client navigations send an x-deployment-id header, and on a mismatch Next.js forces a full page reload instead of a broken client-side navigation.
// next.config.js
module.exports = {
deploymentId: process.env.DEPLOYMENT_VERSION,
};
That forced reload is a reasonable safety net, but it's not free — component state like useState is lost on the reload (though URL state and localStorage survive it), so it's worth communicating to your team that a "hard refresh" during a deploy window is expected behavior, not a bug.
Cache tag invalidation doesn't propagate across instances by default. Calling revalidateTag() on one instance only clears that instance's own cache. The others keep serving stale content until they separately discover the same invalidation. If you're running a custom cache handler for multi-instance caching (as above), you need to also implement a refreshTags() method that syncs tag invalidation state from your shared store before each request — otherwise your carefully built shared cache backend still leaves a window where different users see different content depending on which instance answered their request.
Streaming, Suspense, and Reverse Proxy Buffering
The App Router's streaming responses — the mechanism behind loading.js files and Suspense boundaries — depend on the response actually flowing to the client incrementally rather than being buffered and sent all at once. This is where a reverse proxy that was configured correctly for a traditional request/response app can silently break streaming without throwing any errors.
nginx buffers responses by default, and the fix is a single header:
// next.config.js
module.exports = {
async headers() {
return [
{
source: "/:path*{/}?",
headers: [
{
key: "X-Accel-Buffering",
value: "no",
},
],
},
];
},
};
But nginx is rarely the only thing between a client and your server. Load balancers need to support chunked transfer encoding or HTTP/2 streaming — some managed load balancers (AWS ALB fronting a Lambda integration is the commonly cited example) buffer by default regardless of what you tell nginx. Every hop in the chain needs to pass chunks through without buffering, or streaming quietly degrades into "wait for the whole page, then render it" — which, if you're relying on Partial Prerendering specifically for its time-to-first-byte improvement, defeats the entire point of using it.
If your pages seem to render fine but you never actually observe the progressive loading states you built loading.js files for, this buffering chain is the first thing to audit — not your React code.
Choosing the Right Option for Your Project
With all of the above in mind, the decision tends to come down to a small number of real questions. Does any route in your app need Server Actions, Route Handlers that read the request, or per-request dynamic rendering? If yes, static export is off the table regardless of how appealing "just files on a CDN" sounds. Do you need Kubernetes-style reproducibility, or are you deploying to a single environment you control directly? That's Docker versus a plain Node.js process — same feature set, different operational shape. Are you deploying to a platform that advertises Next.js support? Check whether it's a verified adapter or an independent integration before you build anything that depends on a newer or less common feature.
And regardless of which path you pick, the moment you run more than one instance of your app, budget real time for the cache handler, encryption key, and deploymentId configuration covered above. None of it is optional once you scale past a single process — it's just invisible until the day it isn't.
Common Deployment Mistakes Worth Avoiding Up Front
A few failure patterns show up often enough across self-hosted Next.js deployments that they're worth naming directly, rather than waiting to discover them the hard way.
Assuming "it worked in staging" means the cache will behave the same in production. Staging is frequently a single instance; production is frequently several behind a load balancer. A deploy that passes every check in staging can still serve stale ISR content or mismatched Server Actions in production purely because staging never exercised the multi-instance code paths at all. If you're going to run more than one instance in production, test with more than one instance in staging too — not as an afterthought, but as the actual acceptance criteria.
Treating output: "export" as a drop-in swap for a server deployment. It's tempting to reach for a static export late in a project because "it's just files, how much simpler could it get," only to discover a Server Action or a cookie-based redirect somewhere in the codebase that quietly depended on a server being present. This is much cheaper to catch by deciding on your deployment target early, rather than retrofitting a static export onto an app that grew organically around server features.
Forgetting that NEXT_PUBLIC_ variables are frozen at build time. This bites teams that build one image and promote it through dev, staging, and production with different config per environment — a pattern that works perfectly for server-only environment variables, and silently fails for anything prefixed NEXT_PUBLIC_, because that value was already inlined into the JavaScript bundle during the build that happened in CI, long before the image reached any particular environment.
Skipping the reverse proxy because "it's just for internal use for now." Internal tools have a way of quietly becoming externally reachable, or accumulating enough usage that the lack of rate limiting and request validation in front of the Next.js process becomes a real liability. It costs very little to put nginx or an equivalent in front from day one, and it's a much bigger lift to retrofit once the app is load-bearing.
Not testing streaming through the full production network path. It's easy to verify that loading.js and Suspense boundaries work correctly against next dev or even a bare next start on localhost, and then assume that behavior carries through unchanged once a load balancer and a reverse proxy are sitting in front of it in production. Buffering introduced anywhere in that chain is invisible in local testing and only shows up once real infrastructure is involved — so it needs to be verified against a staging environment that mirrors the production network path, not just against a local server.
Rebuilding independently per instance during a "rolling" deployment. If each instance in a rolling deployment runs its own next build rather than being built once and distributed, you get a different Server Function encryption key and a different implicit build ID per instance — which reintroduces the exact multi-instance problems this article covers, even though it looks, on the surface, like a single coordinated deployment.
Key Takeaways
| If your app needs... | Choose |
|---|---|
| Every App Router feature, full control over infra | Node.js server (next start) |
| Reproducible builds across environments, Kubernetes | Docker with output: "standalone" |
| No server-side logic at all, pure static content | Static export (output: "export") |
| A platform-specific, tested integration | A verified adapter (Vercel, Bun) |
| Multiple running instances | Custom cache handler + deploymentId + a pinned NEXT_SERVER_ACTIONS_ENCRYPTION_KEY |
| Streaming/Suspense to actually stream | A reverse proxy chain with buffering disabled end-to-end |
Deployment isn't a single decision you make once — it's a set of trade-offs that compound as your app grows past a single instance. Getting the initial choice right matters less than understanding what each option quietly assumes about your infrastructure, because that's what determines whether your app still behaves correctly on the day you need to scale it.


