open atlas
↑ Back to track
Next.js, zero to senior NEXT · 06 · 01

Self-host vs Vercel: standalone anatomy, build-time env baking, and what the platform actually sells

output standalone emits server.js plus a file-traced node_modules subset, but .next/static, public and sharp are yours to ship. NEXT_PUBLIC_ vars are inlined into client bundles at build time, and the honest cost math is per-request metering versus a flat VM plus CDN.

NEXT Senior ◷ 18 min
Level
FoundationsJuniorMiddleSenior

The quarter the Vercel bill crosses four thousand dollars — image-optimization overages plus origin transfer from one press-cycle traffic spike — the platform team gets the order: move it to our Kubernetes. An engineer finds output: 'standalone' in the docs, writes a five-line Dockerfile, the image builds, the pod goes green. Then the launch checklist fails in ways that map one-to-one onto what the platform had been silently doing. The page renders as unstyled HTML because every /_next/static asset 404s — standalone does not copy them. The health check cannot reach the container at all, because server.js binds to localhost until HOSTNAME=0.0.0.0 is set. Hero images crawl because sharp never survived into the runtime image. Three days later it all works — and a week after that, staging quietly calls the production API, because NEXT_PUBLIC_API_URL was baked into the client bundle when the one shared image was built. Nothing in this list is a bug. It is the exact inventory of what Vercel was selling.

What output standalone actually emits

With output: 'standalone' in next.config.js, next build does more than compile: it runs file tracing over the server build — static analysis of every require/import reachable from your server code — and emits .next/standalone/, containing a minimal server.js (a thin HTTP server wrapping the Next request handler) plus only the subset of node_modules the trace proved necessary. A repo whose node_modules weighs 800 MB typically traces down to 50–150 MB, and a multi-stage node:20-alpine image lands around 150–250 MB instead of a gigabyte-plus.

What the trace deliberately does not include is everything that is not server code. .next/static/ — all client JS, CSS, and build-hashed assets — is emitted by the build but not copied into standalone, because the intended design is that a CDN serves it; server.js will serve those paths only if you copy the folder next to it. Same for public/. And image optimization via next/image needs sharp in the runtime image: modern Next pulls it as an optional dependency, but pruned installs and alpine builds routinely drop it, and without it /_next/image falls back to a slow path or fails outright. The last trap is invisible until the first container deploy: server.js reads PORT and HOSTNAME from the environment, and without HOSTNAME=0.0.0.0 it binds where no load balancer can reach it.

FROM node:20-alpine AS builder
WORKDIR /app
COPY . .
RUN corepack enable && pnpm install --frozen-lockfile && pnpm build
# next.config.js: { output: 'standalone' }

FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production HOSTNAME=0.0.0.0 PORT=3000
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static  # NOT in standalone — copy or 404
COPY --from=builder /app/public ./public              # NOT in standalone — copy or 404
EXPOSE 3000
CMD ["node", "server.js"]

Env vars: baked at build versus read at runtime

NEXT_PUBLIC_* variables do not exist at runtime in the browser — there is no environment there to read. The bundler string-replaces every process.env.NEXT_PUBLIC_X occurrence with the literal value present at next build time, the same way a define-plugin inlines constants. That is the whole mechanism behind the classic incident: ops changes NEXT_PUBLIC_API_URL in the deployment manifest, restarts every pod, and nothing happens — the client bundle still contains the old string, character for character, because no rebuild occurred. Server-side code is the opposite: plain process.env.SECRET in a route handler or server component is read live from the process on each request (with one caveat — values captured while prerendering a static page are frozen at build time too).

This collides head-on with the build-once, promote-everywhere discipline most container shops run. If staging and production share one image, every NEXT_PUBLIC_ value in it belongs to whichever environment built it. Your honest options: rebuild per environment (cheap with build cache, but now images are not identical artifacts); keep environment-varying values out of NEXT_PUBLIC_ entirely and pass them from the server — read process.env in a server component and hand the value down as a prop; or inject a runtime config object into the HTML from a layout. The rule of thumb that survives audits: NEXT_PUBLIC_ is for values that are genuinely constant per build artifact, not per environment.

Quiz

Ops changes NEXT_PUBLIC_API_URL in the k8s manifest and restarts all pods. The browser keeps calling the old URL. Why, and what actually fixes it?

What Vercel actually sells — and where the cost lines cross

Strip the marketing and the platform sells four hard things. A globally replicated ISR/data cache: revalidation propagates to every region without you running a shared store. An image-optimization fleet: resize-on-demand at the edge, cached, no sharp capacity planning. An anycast edge network in front of everything. And zero-config preview deployments — a full environment per pull request, which is genuinely the hardest item to rebuild yourself. Together, these four items answer the question “what am I actually paying for?” — and without understanding them, you will repay them in engineering hours after switching. A container plus a commodity CDN replicates the rest cheaply: the CDN takes static assets and cached pages, sharp handles images on your CPU, and the load balancer is your edge. One thing does not carry over silently, and it previews the next two lessons: the default ISR cache is per-pod disk, so the moment you run two replicas, the single-truth cache Vercel gave you stops existing.

The cost math has a shape worth memorizing rather than exact prices, because prices drift. Serverless billing is roughly linear in invocations, GB-hours, image transformations, and egress; a VM is a flat constant with a utilization ceiling. Sustained traffic favors flat; spiky traffic favors metered. A steady 50–100 req/s of SSR fits comfortably on two 4-vCPU boxes — order of 50–100 dollars a month plus CDN egress — while the same sustained load on per-request metering lands an order of magnitude higher. Invert the profile and the answer inverts: 0.5 req/s baseline with rare 50× press spikes is exactly what metering absorbs with zero capacity planning, while the VM must be provisioned for the spike and idles the rest of the quarter. The crossover is not subtle in either direction; what is subtle is pricing in the engineering weeks for the shared cache, the image pipeline, and per-PR environments.

Why this works

Why does standalone refuse to copy .next/static when server.js is perfectly capable of serving those files? Because serving build-hashed immutable assets from a Node process is the fallback, not the design. Those files are the textbook CDN payload — content-addressed names, cache-forever headers — and a single-threaded Node server spending its event loop on static file IO is the most expensive possible way to ship them. The omission is an opinion: put a CDN in front, point assetPrefix at it, and let server.js do only the work that needs a server.

Quiz

A dashboard does steady 60 req/s of SSR around the clock, no preview-deploy usage, modest image traffic. Which billing shape wins, and what is the honest caveat?

Recall before you leave
  1. 01
    What lands inside .next/standalone, what must you add by hand, and which two defaults break the first container deploy?
  2. 02
    Why does changing NEXT_PUBLIC_API_URL at runtime do nothing, and what are the three honest ways out?
Recap

output standalone turns next build into a packaging step: file tracing walks the server require graph and emits server.js plus only the node_modules subset it proved necessary — 50-150 MB instead of 800 — but everything client-facing is excluded by design. You COPY .next/static and public/ into the image (or point assetPrefix at a CDN), you keep sharp alive through pruning for next/image, and you set HOSTNAME=0.0.0.0 because server.js otherwise binds where no load balancer can reach. Environment variables split into two regimes with nothing in between: NEXT_PUBLIC_ values are string-replaced into client bundles at build time — the changed-the-env-nothing-happened incident is not a caching problem, it is compiled bytes — while plain server-side process.env is read live per request, except values frozen during build-time prerendering. That split breaks build-once-promote-everywhere unless env-varying values come from the server or you accept per-environment rebuilds. What the platform actually sells: a globally replicated ISR cache, an image-optimization fleet, an anycast edge, and per-PR preview environments — the last being the hardest to rebuild. A container plus CDN replicates the rest cheaply, with one loaded exception this unit returns to: the default ISR cache is per-pod disk, so the second replica silently forks your cache. On cost, match billing shape to traffic shape: sustained 50-100 req/s lives an order of magnitude cheaper on flat VMs; near-zero baselines with rare 50x spikes are what per-request metering is for — and the subtle line item in either direction is the engineering weeks for the cache, images, and previews. Now when you see a Vercel bill spike or a self-host migration proposal, you know exactly which four services to price out before signing anything off.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 6 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

Apply this

Put this lesson to work on a real build.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.