cf.completefrontend
Open lab My progress
THE NEXT.JS FIELD GUIDE

Self-hosting Next.js

Run Next.js on your own servers: the standalone output, a production Dockerfile, a CDN and proxy in front, one cache for many instances, and version skew.

By the end, you can
  • 01
    Build and run the standalone outputSay what .next/standalone holds, copy in the two folders it leaves out, and package it in a multi-stage Dockerfile that runs as a user that isn't root.
  • 02
    Put a CDN and a proxy in frontRead the Cache-Control Next.js sends for each kind of answer, keep streaming working through nginx, and trust forwarded headers only from a proxy that checks the host and sets them itself.
  • 03
    Run many instances safelyShare both caches with a tag index, keep the same encryption key across builds, give each deployment its own deploymentId, and know what image optimization costs each server.

The tea shop on servers you run

Self-hosting means running the app on servers you manage: your own computers, or virtual machines and container services you rent. Deploying Next.js showed that a Node.js server or a Docker container runs every feature. Running one in production takes a few more decisions:

  • what goes onto the server, and how to pack it into an image;
  • what sits in front of the server: a CDN, and a reverse proxy;
  • what several copies of the server must share, so they agree with each other;
  • what happens to open pages when you deploy a new version;
  • what resizing images costs the server.

Here is the tea shop, built for that. Its next.config.ts sets output: "standalone" on line 6. This example is a real Next.js 16.4.3 project: it was built with next build, two folders were copied, and the shop was started with node .next/standalone/server.js, not next start. Then real Chrome opened it.

The tea shop, served from its standalone folderNext.js
app/cart/menu/public/
next.config.ts
import type { NextConfig } from "next"; const nextConfig: NextConfig = {  cacheComponents: true,  partialPrefetching: true,  output: "standalone",  async headers() {    return [      {        source: "/:path*{/}?",        headers: [{ key: "X-Accel-Buffering", value: "no" }],      },    ];  },}; export default nextConfig;
Recorded from Next.js 16.4.3
$ next build▲ Next.js 16.4.3 (Turbopack)- Environments: .env.production- Cache Components enabled- Partial Prefetching enabled   Creating an optimized production build ...✓ Compiled successfully  Running TypeScript ...  Finished TypeScript ...  Collecting page data ...✓ Generating static pages (5/5)  Finalizing page optimization ... Route (app)      Revalidate  Expire┌ ○ /├ ○ /_not-found├ ◐ /cart└ ○ /menu                1h      1d ○  (Static)             prerendered as static content◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content $ cp -r public .next/standalone/ && cp -r .next/static .next/standalone/.next/ # What .next/standalone holds: 1276 files, 46.4 MB (deeper folders added up).next/standalone/.env.production  50 bytes.next/standalone/.next/BUILD_ID  21 bytes.next/standalone/.next/app-path-routes-manifest.json  148 bytes.next/standalone/.next/build-manifest.json  2.0 kB.next/standalone/.next/package.json  20 bytes.next/standalone/.next/prerender-manifest.json  6.6 kB.next/standalone/.next/required-server-files.json  10.8 kB.next/standalone/.next/routes-manifest.json  2.1 kB.next/standalone/.next/server/  1.1 MB, 124 files.next/standalone/.next/static/  592.7 kB, 12 files.next/standalone/node_modules/@emnapi/runtime/  66.5 kB, 4 files.next/standalone/node_modules/@img/colour/  46.7 kB, 3 files.next/standalone/node_modules/@img/sharp-darwin-arm64/  281.3 kB, 3 files.next/standalone/node_modules/@img/sharp-libvips-darwin-arm64/  18.2 MB, 6 files.next/standalone/node_modules/@img/sharp-wasm32/  9.3 MB, 5 files.next/standalone/node_modules/@next/env/  11.0 kB, 2 files.next/standalone/node_modules/@swc/helpers/  30.7 kB, 5 files.next/standalone/node_modules/client-only/  467 bytes, 2 files.next/standalone/node_modules/detect-libc/  11.4 kB, 5 files.next/standalone/node_modules/next/  14.6 MB, 993 files.next/standalone/node_modules/react-dom/  1.4 MB, 12 files.next/standalone/node_modules/react/  22.1 kB, 9 files.next/standalone/node_modules/semver/  68.7 kB, 47 files.next/standalone/node_modules/sharp/  631.5 kB, 29 files.next/standalone/node_modules/styled-jsx/  22.3 kB, 4 files.next/standalone/package.json  391 bytes.next/standalone/public/logo.svg  142 bytes.next/standalone/server.js  8.0 kB $ node .next/standalone/server.js▲ Next.js 16.4.3- Local:         http://127.0.0.1:3000- Network:       http://127.0.0.1:3000✓ Ready✓ Running next.config took 3ms

Open the Terminal tab. After $ next build comes the copy command, then a list of what .next/standalone holds, then the server starting: ✓ Ready. Open Steps: Chrome loaded the page with every script, and after a click, the button says Added. The docs say that folder can be deployed on its own, without installing node_modules.

Left, the project folder: app, public, and node_modules with every package installed. An arrow labeled next build leads right, to the .next/standalone folder: server.js, a .next folder with the server's code, and node_modules with only the packages the server uses. Under it, two faded cards, public and .next/static, are marked: copy these in.the projectapp/public/node_modules/every package, dev tools toonext build.next/standaloneserver.js.next/: server codenode_modules/: tracedpublic/.next/static/not in it: copy these in, or serve them from a CDN
output: "standalone" makes next build write .next/standalone: a server.js, the server's code, and only the packages it traced. The browser's files aren't in it: public and .next/static go to a CDN, or get copied in.
Real-life analogyPacking for a market stall

A tea shop that sells at markets doesn't bring its storeroom. It packs one crate with the teas it will sell and the scale it weighs them on. The signs and leaflets travel in their own bag, or wait at the market's front desk. With three stalls in three markets, all of them read the same price list, so a new price reaches every stall at once.

In real life: The shop's whole storeroom
In Next.js: The project, with every package in node_modules
In real life: One crate with the teas and the scale the stall uses
In Next.js: .next/standalone: server.js and only the traced packages
In real life: The signs and leaflets, in their own bag
In Next.js: public and .next/static, copied in or on a CDN
In real life: Several stalls reading one shared price list
In Next.js: Many instances reading one cache store
The main idea

Self-hosting is running Next.js's server on machines you manage, and deciding what each one needs and what they share. Build a standalone folder and put the browser's files beside it or on a CDN. Put a proxy in front that passes streams through. Give every instance one cache store and the same encryption key, and each deployment its own id.

output: 'standalone': a folder that runs on its own

During every build, Next.js traces which files the server can load: it follows each import, require and file read from your pages and from its own server code. With output: "standalone", it copies exactly those files into .next/standalone, beside a small server.js. These are the commands that build and run it, without Docker:

Build, copy the browser's files, start the serverShell
npm run build
cp -r public .next/standalone/ && cp -r .next/static .next/standalone/.next/
node .next/standalone/server.js

Line 1 builds. Line 2 copies public into the folder, and .next/static into its .next. Line 3 starts the server. The list in the Terminal tab above shows what the folder holds:

  • server.js: the server. It listens on the port in the PORT environment variable, 3000 if there's none, and on the address in HOSTNAME, 0.0.0.0 (every network the computer has) if there's none. The recorder set HOSTNAME=127.0.0.1, which is why the Terminal says so.
  • .next/: the build's manifests and .next/server/, the server code for every route and the pages prerendered during the build.
  • node_modules/: only the packages the server uses: next, react, react-dom, sharp with its @img packages for the image optimizer, and a few small ones they need.
  • package.json, and .env.production. The build printed - Environments: .env.production: it loaded the file, and copied it into the folder too.

That last point matters. Anything in an .env file that the build loads ends up in the folder, and so in any image made from it. Keep secrets out of those files, and give them to the server when it starts instead (Server-only code and environment variables).

Size is the other point. The first version in the playground below also lists the project's whole node_modules. On the computer that recorded it, the standalone folder held about 46 MB; the project's node_modules about 352 MB. The folder leaves out TypeScript, @next/swc-…, the native program that compiles and bundles during the build, and most of next itself. (Sizes change from one computer to another: sharp installs a different binary for each operating system.)

What the folder leaves out on purpose is the browser's files: public and .next/static. The docs say those should ideally be served by a CDN; if not, copy them in, and server.js serves them. Pick a version to see what happens without them:

Playground: what does the standalone server need beside it?
The tea shop's standalone build, served with node .next/standalone/server.jsNext.js
app/cart/menu/public/
next.config.ts
import type { NextConfig } from "next"; const nextConfig: NextConfig = {  cacheComponents: true,  partialPrefetching: true,  output: "standalone",  async headers() {    return [      {        source: "/:path*{/}?",        headers: [{ key: "X-Accel-Buffering", value: "no" }],      },    ];  },}; export default nextConfig;
Recorded from Next.js 16.4.3

Each step was taken in real Chrome against node .next/standalone/server.js. Pick one to see the page after it.

localhost:3000/Tea shop
Tea shop logo40 × 40Teas Menu Cart
Our teas

Mint tea: $3 Add to cart

Focus
nothing: the page itself
Scrolled
0 px down

Errors in Chrome's console during this step (9)

  1. Failed to load resource: the server responded with a status of 404 (Not Found)
  2. Failed to load resource: the server responded with a status of 404 (Not Found)
  3. Failed to load resource: the server responded with a status of 404 (Not Found)
  4. Failed to load resource: the server responded with a status of 404 (Not Found)
  5. Failed to load resource: the server responded with a status of 404 (Not Found)
  6. Failed to load resource: the server responded with a status of 404 (Not Found)
  7. Failed to load resource: the server responded with a status of 404 (Not Found)
  8. Failed to load resource: the server responded with a status of 404 (Not Found)
  9. Failed to load resource: the server responded with a status of 404 (Not Found)

Requests the page made during this step (10), files and other sites included

  1. GET/200a whole page
  2. GET/logo.svg404text/plain
  3. GET/_next/static/chunks/1x6obpmlu1_c2.js404text/plain
  4. GET/_next/static/chunks/06bs-nf19b2js.js404text/plain
  5. GET/_next/static/chunks/2ki52vdnbo2ft.js404text/plain
  6. GET/_next/static/chunks/3l_qmp-61z4yc.js404text/plain
  7. GET/_next/static/chunks/turbopack-3-ry682tkyiqk.js404text/plain
  8. GET/_next/static/chunks/3oh00mkbkq8u6.js404text/plain
  9. GET/_next/static/chunks/05qpawisz0721.js404text/plain
  10. GET/logo.svg404text/plain
Try it yourself
Nothing copied

node .next/standalone/server.js serves only what the build put in the folder. The page's HTML arrives, but /logo.svg and the page's first script answer 404. In Steps, every script and the logo fail, and Add to cart does nothing when clicked.

Both versions are the same build from Next.js 16.4.3; only the copying differs. Steps shows the page in real Chrome after each step, with every file it asked for. Reset goes back to the first version.

With nothing copied, the server still started, and / still came back with its HTML: Our teas, Mint tea: $3, a button. But /logo.svg and the page's first script answered 404. In Steps, the first step lists every script the page asked for, each 404, and Chrome's console logged each failure. The second step clicked Add to cart, and nothing changed: React never loaded, so the button had no click handler. With the two folders copied, every file answered 200, and the click turned the button into Added.

One more thing the recordings show. Started with next start instead, the same build still serves the shop, but prints a warning:

The standalone build, started with next startNext.js
app/cart/menu/public/
next.config.ts
import type { NextConfig } from "next"; const nextConfig: NextConfig = {  cacheComponents: true,  partialPrefetching: true,  output: "standalone",  async headers() {    return [      {        source: "/:path*{/}?",        headers: [{ key: "X-Accel-Buffering", value: "no" }],      },    ];  },}; export default nextConfig;
Recorded from Next.js 16.4.3
$ next build▲ Next.js 16.4.3 (Turbopack)- Environments: .env.production- Cache Components enabled- Partial Prefetching enabled   Creating an optimized production build ...✓ Compiled successfully  Running TypeScript ...  Finished TypeScript ...  Collecting page data ...✓ Generating static pages (5/5)  Finalizing page optimization ... Route (app)      Revalidate  Expire┌ ○ /├ ○ /_not-found├ ◐ /cart└ ○ /menu                1h      1d ○  (Static)             prerendered as static content◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content $ next start▲ Next.js 16.4.3- Local:         http://127.0.0.1:3000- Network:       http://127.0.0.1:3000✓ Ready⚠ "next start" does not work with "output: standalone" configuration. Use "node .next/standalone/server.js" instead.

The Terminal's last line says "next start" does not work with "output: standalone" configuration. Use "node .next/standalone/server.js" instead. With standalone output, start the server the folder holds.

A production Dockerfile

The docs' official Docker example (examples/with-docker, at the 16.4.3 release) builds the standalone folder inside Docker, in three stages: separate images, each starting fresh, where a later one copies only what it needs from an earlier one. This is the tea shop's version of it, shortened to npm. Docker isn't run on this page; the recordings above ran the same commands its last stage runs.

Dockerfile: three stages, after the official exampleDockerfile
ARG NODE_VERSION=24.13.0-slim

# Stage 1: install the packages from the lockfile.
FROM node:${NODE_VERSION} AS dependencies
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --no-audit --no-fund

# Stage 2: build, with output: "standalone" in next.config.ts.
FROM node:${NODE_VERSION} AS builder
WORKDIR /app
COPY --from=dependencies /app/node_modules ./node_modules
COPY . .
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN --mount=type=secret,id=actions_key,env=NEXT_SERVER_ACTIONS_ENCRYPTION_KEY npm run build

# Stage 3: only what the server needs, run by a user that isn't root.
FROM node:${NODE_VERSION} AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=builder --chown=node:node /app/public ./public
RUN mkdir .next && chown node:node .next
COPY --from=builder --chown=node:node /app/.next/standalone ./
COPY --from=builder --chown=node:node /app/.next/static ./.next/static
USER node
EXPOSE 3000
CMD ["node", "server.js"]

Line by line:

  1. Line 1 names the Node.js image once. The official example pins the LTS release current when it was written, and says to keep it updated.
  2. Lines 4 to 7, stage 1, copy only the package files and run npm ci. Docker keeps each step's result, so as long as these two files don't change, it reuses the installed packages.
  3. Lines 10 to 16, stage 2, take those packages, copy the project, and build it. Line 15 turns off Next.js's anonymous telemetry; the official example leaves that line commented out, so its builds send it unless you remove the #. Line 16 builds with the Server Actions key, handed in as a BuildKit secret: it exists only while that one command runs, and isn't saved in the image's settings or history. (The official example has no key; why the shop needs one, and where it ends up, is in the version skew part below.)
  4. Lines 19 to 31, stage 3, start from a clean image. Lines 22 and 23 set the port and make the server listen on every network inside the container, 0.0.0.0, so docker run -p can reach it. Line 25 copies public; lines 27 and 28 copy the standalone folder and .next/static, the same two folders the playground needed. --chown=node:node makes them belong to node, a user the Node.js image already has, and line 26 lets that user write to .next, where the server keeps the pages it makes later.
  5. Line 29 switches to that user. If someone breaks into the server, they get a user that can't change the system. Line 31 starts server.js.

The last image holds Node.js, the standalone folder and the two copied folders: no source code, no compiler, no dev tools. Because sharp is installed during stage 1 inside Linux, the folder gets sharp's Linux binary, not the one for the computer you typed docker build on. A .dockerignore keeps the rest of your computer's files out of the build:

.dockerignore: what never goes into the imageText
node_modules
.next
.git
.env
.env*.local
Dockerfile
.dockerignore

Lines 1 and 2 keep your own node_modules and .next out, so the image installs and builds its own. Lines 4 and 5 keep .env and local env files out. The official example's .dockerignore warns about the same thing the standalone folder showed: any env file left in is loaded by next build and copied into .next/standalone. It keeps .env.production for settings that aren't secret, and passes secrets with docker run -e.

A CDN and a reverse proxy in front

The docs recommend a reverse proxy, like nginx, in front of the Next.js server rather than exposing it to the internet. A reverse proxy is a server that receives every request first and passes it on. It can turn away malformed requests, visitors who send very slowly to tie up connections, bodies that are too big, and too many requests from one place, so the Next.js server spends its time rendering. Further out, a CDN keeps copies of answers on servers close to visitors.

From left to right: the browser, a CDN, nginx as a reverse proxy, and two instances of the tea shop. Under the CDN: keeps /_next/static for a year, keeps static pages for their s-maxage, never keeps private pages. Under nginx: TLS and limits, sets X-Forwarded headers, passes streamed answers through. Both instances run next start.browserCDNkeeps /_next/static a yearkeeps ○ pages for s-maxagenever keeps private pagesnginxTLS and limitssets X-Forwarded-*no buffering: streams passinstance 1instance 2next start, same build
A request passes a CDN, which keeps what Cache-Control allows, then a reverse proxy, which guards the servers, says who the visitor is in X-Forwarded-… headers, and must pass streamed answers through as they come. Behind them, any instance can answer.

What each answer lets a CDN keep

Next.js tells caches what they may keep with Cache-Control, and the recording of the standalone shop has one answer of each kind. Open its Network tab above and pick each request. Each row is one of them:

Cache-Control on each kind of answer
AnswerCache-ControlWhat a CDN may do
/, prerendered (○)s-maxage=31536000Keep it for up to a year.
/menu, cached for an hours-maxage=3600, stale-while-revalidate=82800Keep it an hour, then send the old copy for up to 23 more hours while it asks for a new one.
/cart, made for each request (◐)private, no-cache, no-store, max-age=0, must-revalidateNever keep it. Each visitor gets their own.
A script under /_next/static/public, max-age=31536000, immutableKeep it for a year without asking again. A changed file gets a new address.
/logo.svg, from publicpublic, max-age=0Keep it, but ask the server each time whether it changed.

A CDN that follows these headers keeps the shop's static pages and files, and never keeps the cart. Two rules from the docs' CDN guide:

  • Keep rsc and _rsc. The same address can answer with HTML or with the RSC payload the router asks for. Every page answer names the request headers it depends on in Vary (the recording shows rsc, next-router-state-tree, next-router-prefetch…). Many CDNs ignore Vary, so the router also adds an _rsc value to the address. A CDN must pass the rsc header on and keep _rsc in its cache key, or it can send HTML to the router.
  • Purge the CDN yourself. revalidateTag and revalidatePath clear Next.js's own cache, not the CDN's copy. It keeps that until its s-maxage runs out, unless you also call your CDN's purge API (Revalidating data).

A proxy that passes streams through

nginx, by default, buffers an upstream server's answer: pieces can wait in its buffers instead of going straight on. For the cart, the visitor may then wait for the whole page instead of seeing the shell first (Streaming). The docs' fix is the header X-Accel-Buffering: no, which tells nginx not to hold that answer back. Lines 7 to 14 of the shop's next.config.ts add it to every answer, and the recording shows it on each one, files included. The docs add that every load balancer and proxy between the visitor and the server must stream too.

Headers the proxy writes, and headers it replaces

Behind a proxy, Next.js no longer sees the visitor directly. The proxy tells it in X-Forwarded-… headers. This nginx setup spreads requests over two instances:

nginx: unknown hosts refused, two instances behind one nameNginx
upstream tea_shop {
  server 10.0.0.11:3000;
  server 10.0.0.12:3000;
}

# Any name but the shop's: close the connection, with no answer.
server {
  listen 443 ssl default_server;
  ssl_certificate /etc/nginx/certs/teashop.example.pem;
  ssl_certificate_key /etc/nginx/certs/teashop.example.key;
  return 444;
}

server {
  listen 443 ssl;
  server_name teashop.example;
  ssl_certificate /etc/nginx/certs/teashop.example.pem;
  ssl_certificate_key /etc/nginx/certs/teashop.example.key;

  location / {
    proxy_pass http://tea_shop;
    # Only teashop.example reaches this block, so say exactly that.
    proxy_set_header Host teashop.example;
    proxy_set_header X-Forwarded-Host teashop.example;
    proxy_set_header X-Forwarded-For $remote_addr;
    proxy_set_header X-Forwarded-Proto $scheme;
  }
}

Lines 1 to 4 list the two instances. Lines 7 to 12 are the default server: nginx sends it every request whose Host header names no server it knows, and line 11 closes the connection without an answer (444 is nginx's code for exactly that). Lines 14 to 28 serve the one name the shop owns: line 16 matches teashop.example, and lines 17 and 18 hold its certificate. Line 21 passes each request to one instance. Lines 23 to 26 set the headers that describe the visitor: the shop's name they asked for, their IP address, and whether they used HTTPS.

Trusting those headers takes two things together:

  • Replace, don't pass on. Each line sets its header, so whatever a visitor sent under that name is gone. A visitor can send X-Forwarded-Host too, and Multi-tenant apps showed a made-up one arriving at the app untouched.
  • Check the host first. Replacing with the visitor's own Host would only copy their claim. Here, only requests for teashop.example reach lines 23 and 24, which write that fixed name. Without the default server, nginx would treat the first server block as the default, and any made-up name would reach the shop. An app with many domains checks each against its own list, as Multi-tenant apps does.

It matters because Next.js compares a Server Action's Origin with X-Forwarded-Host when there is one (Securing Server Actions). If you'd rather serve /_next/static from a CDN on its own domain, the assetPrefix option makes the pages point there.

Many instances, one cache

One server is rarely enough: you run several instances, copies of the same server, behind a load balancer. Each instance keeps two caches of its own:

The playground runs two instances of the price page from one build, on one computer and one folder. A staff tool, which is a stand-in here (a real one checks who's asking first, as Route Handlers showed), sends a new price to instance 1:

app/api/prices/route.ts: a new price, and the tag expiredTypeScript
import { writeFile } from "node:fs/promises";
import { revalidateTag } from "next/cache";

// A stand-in for the shop's staff tools: a real one checks who is asking first.
export async function POST(request: Request) {
  const { mint } = await request.json();
  await writeFile("data/prices.json", JSON.stringify({ mint }));
  revalidateTag("prices", { expire: 0 });
  return Response.json({ mint });
}

Line 7 writes the new price to the stand-in database, a file both instances read. Line 8 expires the prices tag, which getMintPrice in app/prices.ts is tagged with; expire: 0 means the next request waits for a new copy (Revalidating data). Before you pick a version, guess what instance 2 shows after the change.

Playground: does a new price reach the other instance?
Two instances of the tea shop, from one buildNext.js
app/api/prices/data/
app/prices.ts
import { readFile } from "node:fs/promises";import { cacheTag } from "next/cache"; // A stand-in for the shop's database: a file both instances read.export async function getMintPrice() {  "use cache";  cacheTag("prices");  const prices = JSON.parse(await readFile("data/prices.json", "utf8"));  console.log(`Read the price from the database: $${prices.mint}`);  return prices.mint as number;}
Recorded from Next.js 16.4.3

GET /200

Answered by instance 2 of the app.

Response headers
Vary rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encoding
x-nextjs-cache HIT
x-nextjs-prerender 1
x-nextjs-stale-time 300
X-Powered-By Next.js
Cache-Control s-maxage=900, stale-while-revalidate=31535100
Content-Type text/html; charset=utf-8

The HTML that arrived, one element per line, with the code of its 15 scripts left out

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link rel="preload" as="script" fetchpriority="low" href="/_next/static/chunks/3cfn99dbrcvvp.js">
    <script src="/_next/static/chunks/1j_b1xq_woyiy.js" async=""></script>
    <script src="/_next/static/chunks/2ki52vdnbo2ft.js" async=""></script>
    <script src="/_next/static/chunks/turbopack-3-ry682tkyiqk.js" async=""></script>
    <script src="/_next/static/chunks/1tq6ppe__m8m-.js" async=""></script>
    <title>Tea shop</title>
    <script src="/_next/static/chunks/0cz1d0mv5g_q7.js" nomodule=""></script>
  </head>
  <body>
    <div hidden="">
      <!--$-->
      <!--/$-->
    </div>
    <!--&-->
    <main>
      <h1>Tea shop</h1>
      <p>Mint tea: $<!-- -->3</p>
    </main>
    <!--$-->
    <!--/$-->
    <!--/&-->
    <script id="_R_">…</script>
    <script src="/_next/static/chunks/3cfn99dbrcvvp.js" async=""></script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
  </body>
</html>
Try it yourself
Each instance's own cache

Both instances start with $3. The new price goes to instance 1, and line 8 of app/api/prices/route.ts expires the prices tag there. Instance 1 then shows $5. Instance 2 never heard about it, and still shows $3 from its own cache.

Each version ran two instances of next start from the same build, and sent the same five requests: each instance once, the new price to instance 1, then each instance again. The Terminal shows what each instance printed. Reset goes back to the first version.

With each instance's own cache, instance 2 answered Mint tea: $3 after the change, with X-Nextjs-Cache: HIT. In the Terminal, only instance 1 printed Read the price from the database: $5. The docs put it plainly: revalidateTag on one instance only invalidates the cache on that instance. Even on one disk, each instance answers from its own memory, and keeps its own record of which tags expired.

Two sides. Left, each instance's own cache: a new price goes to instance 1, which shows 5 dollars, while instance 2 still shows 3 dollars from its own memory. Right, one shared store: the new price goes to instance 1, the expired tag goes to the store, and both instances show 5 dollars.Each instance's own cachenew price: mint is $5instance 1mint: $5instance 2mint: $3instance 2 is out of dateOne store for bothone store: pages, tagsinstance 1mint: $5instance 2mint: $5both show the new price
Left: revalidateTag only reaches the cache of the instance that ran it, so instance 2 keeps the old page. Right: both caches live in one store with a tag index, so expiring prices once removes the old page for every instance.

Two handlers on one store

The second version moves both caches into one store that every instance reaches. Each row is one setting it uses:

The two handler options, and the memory beside them
OptionWhat it keepsIts methods
cacheHandlerPages and Route Handler answers (the incremental cache), fetch results kept by the older caching model, and optimized images with images.customCacheHandler.get, set, revalidateTag, resetRequestCache
cacheHandlers"use cache" results, by kind: default, remote, or a name of your own.get, set, refreshTags, getExpiration, updateTags
cacheMaxMemorySizeHow much each instance keeps in its own memory: 50 MB unless it says. 0 keeps nothing there.None: it's a number.
next.config.ts: both caches on one storeTypeScript
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  cacheComponents: true,
  partialPrefetching: true,
  // Pages and Route Handler answers: the incremental cache.
  cacheHandler: require.resolve("./cache-handlers/pages.js"),
  // "use cache" results.
  cacheHandlers: {
    default: require.resolve("./cache-handlers/data.js"),
  },
  // No copy of either cache in each instance's own memory.
  cacheMaxMemorySize: 0,
};

export default nextConfig;

Line 7 names the handler for the incremental cache, cacheHandler, singular. Lines 9 to 11 name the handler for "use cache", cacheHandlers, plural, for its default kind. Line 13 turns off each instance's copy in memory, so the store is the only copy. The store itself is a stand-in for Redis: a folder both instances reach.

cache-handlers/shared-store.js: a stand-in for RedisJavaScript
// A stand-in for Redis: one folder that every instance of the shop can reach.
const { createHash } = require("node:crypto");
const fs = require("node:fs");
const path = require("node:path");
const v8 = require("node:v8");

const folder = path.join(process.cwd(), ".shared-cache");
const fileFor = (key) => path.join(folder, createHash("sha256").update(key).digest("hex"));

module.exports = {
  read(key) {
    return fs.existsSync(fileFor(key)) ? v8.deserialize(fs.readFileSync(fileFor(key))) : undefined;
  },
  write(key, value) {
    fs.mkdirSync(folder, { recursive: true });
    // v8.serialize keeps the Buffer and Map values a page entry holds; JSON.stringify loses them.
    fs.writeFileSync(fileFor(key), v8.serialize(value));
  },
  remove(key) {
    fs.rmSync(fileFor(key), { force: true });
  },
};

Line 7 is the folder. Line 17 writes each entry with v8.serialize, Node.js's own format, which keeps the Buffer and Map values a page entry holds; the docs warn that plain JSON loses them, and navigations then fail. The page handler is the one that makes revalidateTag reach every instance:

cache-handlers/pages.js: pages, with a tag indexJavaScript
// cacheHandler: where every instance keeps pages and Route Handler answers.
const store = require("./shared-store.js");

module.exports = class PagesHandler {
  async get(key) {
    const entry = store.read(`page:${key}`);
    console.log(`pages: get, ${entry ? "found" : "missing"}`);
    return entry ?? null;
  }

  async set(key, data, ctx) {
    if (!data) return;
    // A page carries its tags in a header; a fetch result in ctx.tags.
    const header = data.headers?.["x-next-cache-tags"] ?? "";
    const tags = [...new Set([...(ctx?.tags ?? []), ...header.split(",").filter(Boolean)])];
    store.write(`page:${key}`, { value: data, lastModified: Date.now(), tags });
    // The tag index: for each tag, the keys of the entries that carry it.
    for (const tag of tags) store.write(`tag:${tag}`, [...new Set([...(store.read(`tag:${tag}`) ?? []), key])]);
    console.log(`pages: set, tags ${tags.filter((tag) => !tag.startsWith("_N_T_")).join(", ")}`);
  }

  async revalidateTag(tags) {
    for (const tag of [tags].flat()) {
      for (const key of store.read(`tag:${tag}`) ?? []) store.remove(`page:${key}`);
      store.remove(`tag:${tag}`);
      console.log(`pages: revalidateTag ${tag}`);
    }
  }

  resetRequestCache() {}
};

get, on lines 5 to 9, reads an entry from the store, or answers null. set, on lines 11 to 20, keeps the entry with its tags. A page carries them in a header, x-next-cache-tags (line 14), with tags Next.js adds for the page's own path, which start with _N_T_ and which line 19 leaves out of the log. Line 18 is the tag index: for each tag, the keys of the entries that carry it. revalidateTag, on lines 22 to 28, looks the tag up in that index and removes every entry under it, from the store every instance reads.

cache-handlers/data.js: "use cache" results, with expired tagsJavaScript
// cacheHandlers.default: where every instance keeps "use cache" results.
const store = require("./shared-store.js");

// When any of these tags was last expired, by any instance: 0 if never.
const expiredAt = (tags) => Math.max(0, ...tags.map((tag) => store.read(`expired:${tag}`) ?? 0));

module.exports = {
  async get(cacheKey) {
    const entry = store.read(`data:${cacheKey}`);
    const usable = entry && expiredAt(entry.tags) <= entry.timestamp;
    console.log(`data: get, ${usable ? "found" : "missing"}`);
    return usable ? { ...entry, value: new Response(entry.value).body } : undefined;
  },
  async set(cacheKey, pendingEntry) {
    const entry = await pendingEntry;
    // The value is a stream, which can be read only once: keep its bytes.
    const value = Buffer.from(await new Response(entry.value).arrayBuffer());
    store.write(`data:${cacheKey}`, { ...entry, value });
    console.log(`data: set, tags ${entry.tags.join(", ")}`);
  },
  async refreshTags() {},
  async getExpiration(tags) {
    return expiredAt(tags);
  },
  async updateTags(tags) {
    for (const tag of tags) store.write(`expired:${tag}`, Date.now());
    console.log(`data: updateTags ${tags.join(", ")}`);
  },
};

The "use cache" handler keeps when each tag last expired. updateTags, on lines 25 to 28, writes the time. get answers an entry only if none of its tags expired after the entry was made (line 10), and getExpiration tells Next.js the same. refreshTags, on line 21, is where the docs say a handler syncs tags from the shared store before each request; this one reads the store every time, so there's nothing to sync. It also ignores each entry's lifetime, which a real handler checks.

Now read the second version's Terminal. The build's own render stored the price in the shared store (data: set, tags prices). Instance 1 found no page in the store yet, made one from that stored price, and stored it. Instance 2 found it. The new price went to instance 1: data: updateTags prices and pages: revalidateTag prices. Instance 1's next request missed both, read $5 and stored both again. Instance 2 printed pages: get, found, and found the new page: Mint tea: $5.

For production, the docs say a handler must keep entries where every instance can reach them, like Redis or S3; limit the store's size; serialize entries; keep the tag index in that store; and handle a store that times out or drops. The official Redis cacheHandler example does all of it, beside a "use cache: remote" handler (remote-cache-handler.js). It also skips the store while next build runs. Mine let the build write to it, which is why instance 1 found the price.

deploymentId and version skew

Deploying a new version rarely happens at one moment. A rolling deployment replaces instances one by one, and a visitor's open page keeps running the JavaScript it loaded. For a while, old code talks to new servers. That's version skew, and the docs list what it breaks: files the old page asks for that the new build doesn't have, Server Actions with ids the new build doesn't know, and navigations whose data the old code can't read.

A deploymentId in next.config.ts (or the NEXT_DEPLOYMENT_ID environment variable) gives each deployment a name. Here is the tea shop with one:

The tea shop with a deployment idNext.js
app/cart/menu/public/
next.config.ts
import type { NextConfig } from "next"; const nextConfig: NextConfig = {  cacheComponents: true,  partialPrefetching: true,  // One id for each deployment, like the git commit it was built from.  deploymentId: "tea-shop-7",}; export default nextConfig;
Recorded from Next.js 16.4.3

GET /200

Response headers
Vary rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encoding
x-nextjs-cache HIT
x-nextjs-prerender 1
x-nextjs-stale-time 300
X-Powered-By Next.js
Cache-Control s-maxage=31536000
Content-Type text/html; charset=utf-8

The HTML that arrived, one element per line, with the code of its 16 scripts left out

<!DOCTYPE html>
<html data-dpl-id="tea-shop-7" lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link rel="preload" href="/logo.svg" as="image">
    <link rel="preload" as="script" fetchpriority="low" href="/_next/static/chunks/1x6obpmlu1_c2.js?dpl=tea-shop-7">
    <script src="/_next/static/chunks/06bs-nf19b2js.js?dpl=tea-shop-7" async=""></script>
    <script src="/_next/static/chunks/2ki52vdnbo2ft.js?dpl=tea-shop-7" async=""></script>
    <script src="/_next/static/chunks/1rwi1y71imcts.js?dpl=tea-shop-7" async=""></script>
    <script src="/_next/static/chunks/turbopack-3-ry682tkyiqk.js?dpl=tea-shop-7" async=""></script>
    <script src="/_next/static/chunks/3oh00mkbkq8u6.js?dpl=tea-shop-7" async=""></script>
    <script src="/_next/static/chunks/05qpawisz0721.js?dpl=tea-shop-7" async=""></script>
    <title>Tea shop</title>
    <script src="/_next/static/chunks/0cz1d0mv5g_q7.js?dpl=tea-shop-7" nomodule=""></script>
  </head>
  <body>
    <div hidden="">
      <!--$-->
      <!--/$-->
    </div>
    <header><img src="/logo.svg" alt="Tea shop logo" width="40" height="40"><a href="/">Teas</a> <a href="/menu">Menu</a> <a href="/cart">Cart</a></header>
    <!--&-->
    <main>
      <h1>Our teas</h1>
      <p>Mint tea: $3 <button>Add to cart</button></p>
    </main>
    <!--$-->
    <!--/$-->
    <!--/&-->
    <script id="_R_">…</script>
    <script src="/_next/static/chunks/1x6obpmlu1_c2.js?dpl=tea-shop-7" async=""></script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
  </body>
</html>

Line 7 of next.config.ts names the deployment tea-shop-7; a real pipeline uses something like the git commit it built. In the Network tab, the id shows up three times:

  • In the HTML. <html data-dpl-id="tea-shop-7">, and every script address ends in ?dpl=tea-shop-7.
  • On every file. The page's first script was asked for at its own address, /_next/static/chunks/…js?dpl=tea-shop-7. The query makes each deployment's files distinct for browsers and CDNs; the docs say Next.js doesn't read it.
  • On the router's answers. /menu?_rsc, asked for with the header rsc: 1 the way the router asks for a page's RSC payload (Linking and navigating), came back with x-nextjs-deployment-id: tea-shop-7. The router sends its own id with every navigation, in x-deployment-id.
A browser holds a page from deployment tea-shop-7. A click sends a navigation request with x-deployment-id tea-shop-7. The servers now run tea-shop-8 and answer with x-nextjs-deployment-id tea-shop-8. The router sees two different ids and loads the whole page from the new deployment instead.browsertea-shop-7 pageclick: Menusends: tea-shop-7answer: tea-shop-8serversnow: tea-shop-8ids differ: load the whole page
After a deploy, an open page still runs the old build's JavaScript. With a deploymentId, every navigation answer names the deployment that made it. When it differs from the page's own, the router stops and loads the whole page, so old code never reads a new build's answer.

The router compares the two. In 16.4.3, when the answer's id differs from the page's, it stops and loads the whole page from the server instead (fetch-server-response.ts, lines 259 to 265). The visitor gets the new version, with its own files. What they lose is state kept only in React, like a half-typed form; the address, the URL's query and local storage stay. To avoid even that, route each visitor to the instances of their deployment until they reload; the docs leave that routing to your CDN or host.

Two related settings:

  • The build id. With a deploymentId and no generateBuildId, Next.js uses a fixed build id and relies on the deployment id instead (build/index.ts, lines 1105 to 1110). The id also goes into every "use cache" key, so a new deployment never reads an old one's cached results.
  • The Server Actions key. Next.js encrypts the values an inline Server Action closes over, with a key each build makes for itself (Securing Server Actions). Instances started from one image share it. Instances built separately don't, and the docs say their actions then fail with Failed to find Server Action. Unlike the deployment id, this key doesn't change with each deployment. Set the same one for every build, and keep it from one deployment to the next:
One Server Actions key for every buildShell
# Once: make a key, and keep it in your secret store.
openssl rand -base64 32
# Every build, on every machine, in every deployment: the same key.
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY="<the key>" npm run build
# With Docker: hand it to the build as a secret, never as an ARG or ENV.
docker build --secret id=actions_key,env=NEXT_SERVER_ACTIONS_ENCRYPTION_KEY -t tea-shop .

Line 2 makes 32 random bytes as base64, the length Next.js makes its own keys; 16 and 24 bytes work too. Keep the value in your secret store, never in the repository or an .env file the build copies. Line 4 builds with it. Line 6 does the same with Docker: --secret reads the key from the computer's own environment and gives it to line 16 of the Dockerfile. An ARG or ENV would be saved in the image, where anyone who can pull it could read it.

When does Next.js need the key? Both times:

  • During the build, the key makes each action's id, and the docs say it's embedded in the build output. So the built folder, and any image made from it, holds the key: keep those images in a private registry.
  • At run time, the server reads NEXT_SERVER_ACTIONS_ENCRYPTION_KEY if it's set, and the key the build stored if not (encryption-utils.ts, lines 74 to 80). Setting it at run time too is fine, but only with the same value.

Why keep it across deployments? During a rolling deployment, a page from the old build can still post an action's encrypted values to a new instance, which reads them only if the key is the same. Change the key on purpose, at a quiet moment, never as a side effect of each deploy.

What image optimization costs

next/image works on a self-hosted server with nothing to set up (Optimizing images). The resizing happens on your server, though, and it has costs:

  • Size. The optimizer uses sharp, and the standalone folder above held sharp and its @img packages: about 29 MB of the folder's 46 on the recording computer.
  • Work. The first request for each width, quality and format is resized on the spot, X-Nextjs-Cache: MISS, which takes processor time and memory. On Linux with glibc, the docs point at sharp's advice on the memory allocator to keep that in check.
  • Disk, on each instance. Resized images are kept in .next/cache/images for later requests, on that instance's own disk. Since 16.2, images.customCacheHandler: true lets the cacheHandler from the last section keep them in the shared store instead.
  • Whose images. Each image images.remotePatterns allows is one your server will fetch and resize on request, for anyone who asks. Give every pattern an exact protocol and hostname, and narrow its pathname (and search) as far as you can, like { protocol: "https", hostname: "images.teashop.example", pathname: "/teas/**" }. A wildcard host turns the optimizer into a proxy for anyone's images, resized on your servers' time and bandwidth (Optimizing images covers the patterns).

Two settings move the work elsewhere. Pick each version, and look at the page's <img> in the Network tab:

Playground: who resizes the photo?
The green tea page, with this image setupNext.js
app/public/
app/page.tsx
import Image from "next/image"; export default function Home() {  return (    <main>      <h1>Green tea</h1>      <Image src="/green-tea.jpg" alt="A cup of green tea" width={480} height={320} />    </main>  );}
Recorded from Next.js 16.4.3

GET /_next/image?url=%2Fgreen-tea.jpg&w=640&q=75200

Sent with the request
accept image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8
Response headers
Vary Accept
Cache-Control public, max-age=14400, must-revalidate
Content-Type image/webp
Content-Disposition attachment; filename="green-tea.webp"
Content-Security-Policy script-src 'none'; frame-src 'none'; sandbox;
X-Nextjs-Cache MISS

The body that arrived is an image, image/webp: 640 × 427 pixels, 3.9 kB

Try it yourself
Next.js's optimizer

The default loader writes /_next/image addresses into the srcset. Asked for the 640-pixel copy, this server resized the photo itself: a WebP image, X-Nextjs-Cache: MISS, made with sharp.

Every version was built with Next.js 16.4.3 and served with next start. The Network tab shows the page's img element, and for the optimizer, the image it made. Reset goes back to the first version.

Each row is one choice, who resizes, and what it costs the shop's servers:

Who resizes the photo
ChoiceWho resizesWhat it costs this server
The default loaderThis server, with sharp.Work for each new size (MISS), files in .next/cache/images on each instance, and sharp in every image you ship.
images.unoptimizedNobody.No work here. Every visitor downloads the whole photo.
A custom loaderAn image service.No work here. The service does it, and bills for it.

A proxy or CDN in front must pass the browser's Accept header on to the optimizer, the docs add: it picks WebP or AVIF from it.

The tea shop's self-hosting checklist

Each row is one thing the tea shop sets up before it runs on its own servers, and why:

Before the tea shop goes live on its own servers
Set upWhy
output: "standalone", with public and .next/static copied or on a CDNThe server folder has no browser files of its own.
A multi-stage Dockerfile that runs as node, not rootThe image holds only what runs, and a break-in gets a user with few rights.
A reverse proxy that refuses unknown hosts, sets X-Forwarded-Host, -For and -Proto itself, and passes streams throughNext.js reads who's asking from headers only the proxy wrote, and streamed pages arrive in pieces.
A CDN that follows Cache-Control, and keeps rsc and _rsc in its cache keyStatic files and pages are sent from close by; private pages never are.
cacheHandler and cacheHandlers on one store with a tag index, cacheMaxMemorySize: 0A tag expired on one instance is expired for all.
The same NEXT_SERVER_ACTIONS_ENCRYPTION_KEY for every build and every deployment, passed as a secretEvery instance, old or new, reads every action's encrypted values.
A new deploymentId for each deploymentAn open page from the old deployment reloads instead of breaking.
A stop that sends SIGTERM and waitsRequests in flight and after() callbacks finish.

The question behind most of it: is this one thing for every instance, or does each instance have its own? Sort them:

One for all instances, or each its own?
  • The .next folder the build wrote
  • NEXT_SERVER_ACTIONS_ENCRYPTION_KEY
  • The deploymentId
  • The store cacheHandler and cacheHandlers write to
  • The in-memory copy of the cache, when cacheMaxMemorySize isn't 0
  • .next/cache/images, without images.customCacheHandler
  • The after() callbacks still running
Try it yourself
0 of 7 correct

Sort each thing: must every instance share one, or does each instance have its own?

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Common mix-ups

  • "The standalone folder is the whole app." Not quite. It's the server. The browser's files, public and .next/static, are copied in or served by a CDN; without them, the recorded page couldn't load a single script.
  • "Instances on one disk share a cache." No. In the recording, both instances used one folder, and instance 2 still answered the old price from its memory.
  • "A shared store is enough." Not on its own. revalidateTag has to reach the store too: a tag index, and cacheMaxMemorySize: 0 so no instance answers from its own copy first.
  • "revalidateTag clears the CDN." No. It clears Next.js's cache. A CDN keeps its copy until s-maxage runs out, or until you purge it.
  • "?dpl= sends visitors to the right version." No. Next.js doesn't read it. It makes files distinct per deployment; routing by deployment is your host's job.
  • "Env files stay on the build machine." No. The recorded build copied .env.production into .next/standalone, so it goes wherever that folder goes.

Practice exercises

These exercises are about Next.js projects, which can't run inside this page. Answer first, then use the hints.

Exercise 1 · Warm-upWhat the standalone server lacks

node .next/standalone/server.js serves the shop's HTML, but not two folders. Which two?

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upA built file's Cache-Control

    Which Cache-Control does the standalone server send with a script under /_next/static/?

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeCopy the browser's files

      Write the command that goes on line 2, so the server sends the logo and the scripts.

      Starter codeShell
      npm run build
      # copy the two folders here
      node .next/standalone/server.js
      

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticePredict instance 2

        Two instances use the default caches and both show $3. This route runs on instance 1 with {"mint":5}. What does instance 2 show next?

        Starter codeTypeScript
        import { writeFile } from "node:fs/promises";
        import { revalidateTag } from "next/cache";
        
        // A stand-in for the shop's staff tools: a real one checks who is asking first.
        export async function POST(request: Request) {
          const { mint } = await request.json();
          await writeFile("data/prices.json", JSON.stringify({ mint }));
          revalidateTag("prices", { expire: 0 });
          return Response.json({ mint });
        }
        

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeThe method that reaches every instance

          Which method of this handler removes the old page for both instances when the price changes?

          Starter codeJavaScript
          // cacheHandler: where every instance keeps pages and Route Handler answers.
          const store = require("./shared-store.js");
          
          module.exports = class PagesHandler {
            async get(key) {
              const entry = store.read(`page:${key}`);
              console.log(`pages: get, ${entry ? "found" : "missing"}`);
              return entry ?? null;
            }
          
            async set(key, data, ctx) {
              if (!data) return;
              // A page carries its tags in a header; a fetch result in ctx.tags.
              const header = data.headers?.["x-next-cache-tags"] ?? "";
              const tags = [...new Set([...(ctx?.tags ?? []), ...header.split(",").filter(Boolean)])];
              store.write(`page:${key}`, { value: data, lastModified: Date.now(), tags });
              // The tag index: for each tag, the keys of the entries that carry it.
              for (const tag of tags) store.write(`tag:${tag}`, [...new Set([...(store.read(`tag:${tag}`) ?? []), key])]);
              console.log(`pages: set, tags ${tags.filter((tag) => !tag.startsWith("_N_T_")).join(", ")}`);
            }
          
            async revalidateTag(tags) {
              for (const tag of [tags].flat()) {
                for (const key of store.read(`tag:${tag}`) ?? []) store.remove(`page:${key}`);
                store.remove(`tag:${tag}`);
                console.log(`pages: revalidateTag ${tag}`);
              }
            }
          
            resetRequestCache() {}
          };
          

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeAn open page, a new deployment

            Asha opened the shop while it ran tea-shop-7. Now it runs tea-shop-8, and she clicks Menu. The answer comes with x-nextjs-deployment-id: tea-shop-8. What does the router do?

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              For each question, ask what each instance has of its own, and what they share.

              The self-hosting quiz · 8 questionsScore: first tries count
              1. Question 1 of 8What does output: "standalone" add to next build?

                Choose an answer to see the explanation.

              2. Question 2 of 8The standalone server starts with nothing copied in. What happens when a browser opens /?

                Choose an answer to see the explanation.

              3. Question 3 of 8Which Cache-Control did the recorded /cart, made for each request, come with?

                Choose an answer to see the explanation.

              4. Question 4 of 8nginx holds back the cart's streamed answer until it's complete. What does the docs' fix add?

                Choose an answer to see the explanation.

              5. Question 5 of 8Two instances use the default caches. A new price is sent to instance 1, which calls revalidateTag. What does instance 2 show next?

                Choose an answer to see the explanation.

              6. Question 6 of 8Why does the shared setup set cacheMaxMemorySize: 0?

                Choose an answer to see the explanation.

              7. Question 7 of 8A visitor's page was loaded from deployment tea-shop-7. A click gets an answer with x-nextjs-deployment-id: tea-shop-8. What does the router do?

                Choose an answer to see the explanation.

              8. Question 8 of 8Each instance of the shop is built on its own server. What do they need to share for Server Actions to work across them?

                Choose an answer to see the explanation.

              Key takeaways

              • output: "standalone" writes .next/standalone: server.js, the server code and only the traced packages. Start it with node server.js, and copy in public and .next/static, or serve them from a CDN.
              • A production Dockerfile builds in stages, ships only the standalone folder and the two copied folders, and runs as a user that isn't root. Env files the build loads go into the image.
              • Next.js says what caches may keep: a year for /_next/static and static pages, s-maxage and stale-while-revalidate for cached ones, nothing for pages made per request. A proxy must pass streams through (X-Accel-Buffering: no) and set the forwarded headers itself.
              • Each instance keeps its own caches. To share them, point cacheHandler and cacheHandlers at one store with a tag index, and set cacheMaxMemorySize: 0.
              • A deploymentId marks every page, file and navigation answer; when an open page meets a new deployment, the router loads the whole page. Every build, in every deployment, needs the same NEXT_SERVER_ACTIONS_ENCRYPTION_KEY, passed as a secret.
              • The image optimizer costs size, processor time and disk on each server; a loader or unoptimized moves it away.

              Remember the one-liner.
              Ship only the server, put the browser's files beside it or on a CDN, and make every instance share what must agree: the cache, the key and the deployment id.

              Read it in Next.js's own code

              These links point at release v16.4.3, the version every example on this page was built with. The warning next start printed comes from server/next.ts, lines 390 to 395. The router's deployment check is in fetch-server-response.ts, and the fixed build id in build/index.ts. The official Docker example and Redis handlers are in examples/with-docker and examples/cache-handler-redis.

              Coming next: Static exports. It turns the shop into plain files with output: "export": what still works without a server, what doesn't, and how images and single-page apps fit.

              CompleteFrontend Clear concepts. Working examples.