The standalone Output

next start expects the project's whole node_modules folder to be present. It rarely needs it. The build already traces every import, require and fs call with @vercel/nft, and output: 'standalone' in next.config.mjs turns that trace into a folder you can copy anywhere: .next/standalone, holding a minimal server.js, a package.json, and only the traced part of node_modules. Two folders are left out, because a CDN deployment would serve them elsewhere, so you copy them in by hand:

Building, assembling and starting a standalone deploymentShell
npx next build
cp -r public .next/standalone/
cp -r .next/static .next/standalone/.next/
cd .next/standalone
PORT=3115 HOSTNAME=127.0.0.1 API_BASE_URL=https://api.example.com node server.js

For the demo app du -sm reports 339 MB of node_modules against a 29 MB .next/standalone — eleven times smaller, and nothing on the target machine runs npm 2,036 install. Of those 29 MB, 17 MB is next and 10 MB is sharp with its platform binaries, which arrive automatically because sharp is an optional dependency of next (^0.35.4 today) rather than something you install separately. Image optimization therefore needs no extra step; only an install run with --no-optional, or a musl-based image whose prebuilt binary does not match, needs thought.

PORT and HOSTNAME come from the environment (defaults 3000 and 0.0.0.0; bind to 127.0.0.1 when nginx 75 on the same machine is the only client), and so does every server-side variable read at request time:

Output of 102
▲ Next.js 16.3.5
- Local:         http://127.0.0.1:3115
✓ Ready in 0ms
$ curl -s http://127.0.0.1:3115/status | grep -o '<pre>.*</pre>'
<pre>https://api.example.com</pre>

API_BASE_URL was never present during the build. It reached the page because /status calls connection() before reading it (Production Builds) — the point of a single artifact promoted between environments.

Where the ISR cache lives

Self-hosted, revalidation uses the server's own cache on the local filesystem. Requesting /prices, which sets revalidate = 15, every six seconds shows stale-while-revalidate working with no cache service at all:

Output of 102
t+0s   cache=STALE  page says 2026-09-22T07:19:33.639Z
t+6s   cache=HIT    page says 2026-09-22T07:19:55.981Z
t+12s  cache=HIT    page says 2026-09-22T07:19:55.981Z
t+18s  cache=STALE  page says 2026-09-22T07:19:55.981Z
t+24s  cache=HIT    page says 2026-09-22T07:20:14.068Z

A STALE response is served from the old copy and triggers a regeneration in the background; the next visitor gets the new one, and nobody waits. The regenerated HTML is written straight back over the prerendered file — .next/server/app/prices.html — so that directory must be writable and, to survive a restart, persistent. Containers get this wrong routinely; A Multi-Stage Dockerfile shows the one chown that fixes it.