What `next build` Emits: The Route Table and the Artifacts You Ship
next dev compiles a route the first time you visit it and recompiles on every save. next build does the whole production job up front: type-check the project, evaluate every statically-eligible route on the build machine, write HTML and RSC payloads for it, hash the client bundles, and emit the manifests the server will read at boot. Most "it built fine locally" bugs are really "you only ever ran next dev".
The first thing you see is the route table:
Route (app) Size First Load JS
┌ ○ / 1.4 kB 92.1 kB
├ ● /products/[slug] 0.8 kB 91.5 kB
├ ƒ /cart 0 B 88.2 kB
├ ƒ /checkout 2.1 kB 90.3 kB
└ ○ /_not-found 1.0 kB 89.0 kB
+ First Load JS shared by all 87.4 kB
○ and ● are routes Next.js rendered during the build (static, and ISR pages whose params came from generateStaticParams); ƒ is a route that stays dynamic and renders per request. First Load JS is the number to defend in review — route-specific code plus the shared chunk every page downloads — because it is a direct proxy for how much work the browser does before anything paints.
Because prerendering runs your code on the build machine, build-time-only failures surface here and nowhere else: a process.env key that only exists in development, a fetch to an internal host the CI sandbox cannot reach, a generateStaticParams that throws on one bad product row. The error names the route — Error occurred prerendering page "/products/aurora-lamp" — which is exactly why running a build before every push is cheaper than discovering it in CI.
What lands a route in the ƒ column is equally concrete. Touching a request-scoped API opts that route out of prerendering:
| You call, at render time | Result |
|---|---|
cookies() or headers() |
route becomes dynamic |
searchParams in a page |
that render is dynamic |
fetch(..., { cache: 'no-store' }) |
dynamic data fetch, route dynamic |
export const dynamic = 'force-dynamic' |
dynamic on purpose |
nothing request-scoped + revalidate set |
static or ISR (○/●) |
The classic surprise is one shared layout calling cookies() to read a session: every route under that layout silently drops from static to dynamic, TTFB climbs, and the build table shows ƒ where you expected ○. The route table is your feedback loop — treat an unexpected marker as a bug report from the compiler.
What lands on disk:
| Artifact | What it holds | Who reads it |
|---|---|---|
.next/server/ |
route modules, prerendered HTML + RSC payloads | the Node server |
.next/static/ |
hashed JS/CSS/fonts, immutable by contract | CDN or proxy (long cache) |
.next/cache/ |
fetch/ISR cache and compiler cache | rebuilt, never shipped |
BUILD_ID |
identifies this exact build | server + every response |
prerender-manifest.json, routes-manifest.json, required-server-files.json |
which routes are static, ISR rules, file list | server at boot |
The key property: a build is an immutable artifact. Give it generateBuildId: async () => process.env.GIT_HASH and the same bytes serve staging and production, with only runtime environment variables differing between them — which is the whole design discussed in Managing Secrets Across Environments. Rollback then means "point the platform at the previous artifact", not "rebuild and hope".
In an interview, "what does next build do?" gets a four-beat answer: compile and type-check, prerender every static route by executing its server components, split and hash client bundles with a per-route size report, and write the manifests next start needs so the server can run without touching your source again.
Rehearsing Production with `next start` and a Ten-Line Smoke Test
next start does one thing: it serves the result of next build. No compilation, no hot reload, no error overlay — the same server a platform would run if it kept a long-lived Node process. That makes it the cheapest rehearsal you have.
npm run build # fails loudly if the artifact cannot be produced
npm run start -- -p 4000 -H 0.0.0.0
Two things change compared with next dev, and both catch bugs early. First, next start loads .env.production*, not .env.development* — a developer whose secrets live only in .env.development.local gets undefined the moment they rehearse production, which is precisely the class of bug that otherwise waits for CI. Second, there is no overlay: a crash in a server component becomes a 500 response and a log line, so you find out by requesting the route, not by looking at a terminal.
| Behavior | next dev |
next start |
|---|---|---|
| Compilation | on demand, per route, recompiled on save | none — serves the prebuilt .next |
| Env files loaded | .env.development* |
.env.production* |
| Render errors | red overlay with the stack | HTTP 500 + server log |
| Prerendering | skipped; everything renders per request | executed at build; static files served from disk |
| Caching headers | permissive, dev-oriented | production rules (immutable / no-store / s-maxage) |
| First request | pays compile cost | pays only render cost — the real number |
That last row is why load-testing against next dev produces fiction: you are measuring webpack, not your app.
Make that automatic. A smoke test is ten lines of shell and it converts "the build finished" into "the app answers correctly":
npm run build
npm run start -- -p 4000 &
sleep 3
for p in / /products/aurora-lamp /cart /api/health /does-not-exist; do
curl -s -o /dev/null -w "%{http_code} $p\n" http://127.0.0.1:4000$p
done
# expect: 200 / 200 / 200 / 200 / 404
Status codes alone miss half the story, so check the headers Next promises:
curl -sI http://127.0.0.1:4000/_next/static/chunks/main-8f3c21.js | grep -i cache-control
# cache-control: public, max-age=31536000, immutable
curl -sI http://127.0.0.1:4000/cart | grep -i cache-control
# cache-control: private, no-cache, no-store, max-age=0, must-revalidate
Hashed assets must be immutable (safe to cache forever), while a user-specific page must be no-store (never cached by a proxy). If those two are flipped — or if /cart answers 200 from a cached copy — you have a correctness bug that only shows up for real users behind a CDN, and the local rehearsal caught it for free.
Platforms run your build command in a clean sandbox, so this loop also flushes out lockfile drift (npm ci refusing a stale package-lock.json), a devDependency used at build time, and env keys missing from the build environment. Minutes locally instead of a queued pipeline.
The same script becomes your CI gate with one line, because a non-zero exit from a bad status code fails the job before the platform ever sees the commit:
# GitHub Actions — build, start, probe
- run: npm ci
- run: npm run build
- run: npm run start -- -p 4000 &
- run: |
sleep 5
test "$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:4000/)" = "200"
test "$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:4000/nope)" = "404"
The remaining question is where that artifact runs: a provider's functions, or your own container.
Vercel, Netlify, Fly, Railway: One Build Command, Four Execution Models
Every capable platform detects a Next.js project — it reads the next dependency and your package.json scripts — and fills in next build for you. What differs is what happens to the output, and that difference is the whole decision.
| Vercel | Netlify | Fly.io | Railway | |
|---|---|---|---|---|
| Framework detection | built-in preset | built-in | fly launch writes a Dockerfile |
Nixpacks detects Next.js |
| Build command | next build |
next build (Next Runtime) |
docker build (your file) |
npm run build |
| Runs as | per-route functions + CDN | functions repackaged by the Next Runtime | your container (node server.js or next start) |
your container |
| Env vars | dashboard, 3 scopes, vercel env pull |
contexts: production/preview/development | fly secrets set (restarts the app) |
railway variables --environment production |
| Preview per git push | first-class URL per PR | branch deploys | not built in | preview environments |
ISR / next/image |
native | handled by the runtime | your server does the work | your server does the work |
Vercel authored Next.js, so new framework features land there first, and the integration is deeper than a generic Node host: ISR, image optimization, and streaming work without configuration. The workflow detail worth knowing is vercel env pull, which writes a local .env.local from the project's stored variables — one command keeps your laptop on the same value set as Production or Preview, closing the loop on the environment discipline from Managing Secrets Across Environments. You never run next start there; routes become functions behind their CDN.
Netlify runs the same build and its Next.js Runtime repackages App Router routes as functions, with environment scoping handled by contexts — a value set for production is absent from a preview build. netlify.toml pins the build command so the UI cannot drift from the repo.
Auto-detection is a convenience, not a contract. Pin it in the repo wherever the platform lets you, because a sensible default that silently changes is worse than an explicit one:
# netlify.toml
[build]
command = "next build"
publish = ".next"
[context.production.environment]
NODE_ENV = "production"
// vercel.json — pin the region next to your database
{ "regions": ["fra1"], "crons": [{ "path": "/api/revalidate-feed", "schedule": "0 * * * *" }] }
And each CLI does the same job from your terminal: vercel --prod builds and promotes, netlify deploy --prod follows netlify.toml, fly deploy builds your Dockerfile and rolls machines, railway up uploads and rebuilds. All four are thin wrappers over the same two verbs you already rehearsed locally: next build, then start.
Fly and Railway invert the relationship: detection only chooses your build and start commands, and the process that serves traffic is yours. That means image size, HOSTNAME binding, static caching, and restarts are your problems — which is exactly the territory of Self-Hosting with Standalone Output. In exchange you get long-lived processes, private service-to-service networking, volumes, and per-VM pricing that beats function billing at steady traffic.
The decision rule to say out loud in an interview: choose managed (Vercel, Netlify) when preview deployments and zero operations matter more than control; choose containers (Fly, Railway) when you need stateful services, VPC access, or a fixed monthly cost; and reach for your own servers only when compliance or existing infrastructure demands it — with the same next build artifact either way.
Quiz
1. next dev works fine, but next build fails with "Error occurred prerendering page "/products/[slug]"". What is the most likely cause?
2. Which route and Cache-Control pairing is correct?
3. You deploy the same repo to Fly.io, and separately to Vercel. What does framework detection configure on Fly.io?
4. You run next start and a server variable that exists in .env.development.local reads as undefined. Why?
Flashcards
Question
What four things does `next build` produce?
Click to reveal answer
Answer
It type-checks and compiles the app, prerenders every static route (executing server components to HTML + RSC payloads), splits and hashes client bundles with a per-route size report, and writes the manifests (prerender/routes/required-server-files) plus BUILD_ID that the server reads at boot.
Question
What do ○/● and ƒ mean in the App Router build route table, and which number should gate a review?
Click to reveal answer
Answer
○/● routes were prerendered at build time (static, or ISR with params from generateStaticParams); ƒ routes render dynamically per request. First Load JS is the review gate — route-specific code plus the shared chunk every visitor downloads.
Question
Why run `next start` locally before pushing?
Click to reveal answer
Answer
It is the same server the platform runs: it loads .env.production*, executes prerendering, and applies production cache headers. A ten-line curl loop over /, a product page, /cart, /api/health, and a bad slug (expect 200/200/200/200/404) plus header checks catches env and caching bugs in minutes.
Question
How do Vercel and Netlify differ from Fly and Railway in one sentence each?
Click to reveal answer
Answer
Vercel/Netlify run next build and serve the output as functions behind their CDN with preview deploys and per-context env vars; Fly/Railway run your container with your start command, so image size, static caching, and restarts are your responsibility.
Question
Why set generateBuildId, and what does it buy you?
Click to reveal answer
Answer
It pins one identity (commonly the git SHA) across every container built from the same source, so a multi-instance deploy serves consistent assets and rollback means pointing traffic at the previous artifact instead of rebuilding. Next.js otherwise generates a fresh ID per build.
Revision Notes
Key Takeaways
- 1. next build is a full production simulation: prerendering runs your server components, which is why build-only failures never show up in next dev
- 2. The build emits an immutable artifact — server code, hashed static assets, and manifests — so rollback means repointing traffic, not rebuilding
- 3. next start loads .env.production* and applies production cache headers, making it the cheapest faithful rehearsal available locally
- 4. Framework detection only fills in build/start commands; the execution model (functions + CDN vs your container) is what actually distinguishes the four platforms
- 5. Verify status codes and Cache-Control with a scripted smoke test before every push — caching mistakes are invisible until a CDN is in front
Interview Tips
- • "Walk me through what happens when I run next build" — compile and type-check, prerender static routes, hash and split client bundles with a size report, emit manifests and BUILD_ID; then name which directory serves which purpose
- • "How would you deploy this app?" — build once, set per-environment variables on the platform, let detection fill the build command, rehearse with next start, enable preview deploys per PR, and keep the previous artifact for rollback
- • "How do you know a page is too heavy?" — First Load JS from the build route table plus field p75 numbers; state a budget and say what you would code-split
- • "Static or dynamic — how do you decide?" — per route: cache-friendly reads go static/ISR, anything per-user goes dynamic, and you verify the classification in the build output every time it changes
Cheat Sheet
Deploying to a Node Platform
Build output:
| Artifact | Holds | Consumer |
|---|---|---|
.next/server/ |
route modules, prerendered HTML + RSC payloads | Node server |
.next/static/ |
hashed JS/CSS/fonts | CDN/proxy, immutable |
.next/cache/ |
fetch/ISR + compiler cache | rebuilt, never shipped |
BUILD_ID + manifests |
build identity, static/dynamic map | server at boot |
Pre-deploy rehearsal:
npm run build && npm run start -- -p 4000 &
for p in / /products/aurora-lamp /cart /api/health /nope; do
curl -s -o /dev/null -w "%{http_code} $p\n" http://127.0.0.1:4000$p
done # 200 200 200 200 404
Platform matrix:
| Vercel | Netlify | Fly / Railway | |
|---|---|---|---|
| Build | next build |
next build + Next Runtime |
your Dockerfile / Nixpacks |
| Runs as | functions + CDN | functions + CDN | your container (node server.js, next start) |
| Env vars | dashboard scopes, vercel env pull |
contexts | fly secrets set, railway variables |
| Previews | per PR | branch deploys | bring your own |
Cache headers: hashed asset → public, max-age=31536000, immutable · ISR page → s-maxage=60, stale-while-revalidate · dynamic page → private, no-cache, no-store.