Skip to content
intermediate Phase 16 · Deployment and Production Capstone

Deploying to a Node Platform

Push a production build to a hosting provider.

45m
0 problems
Topic Progress 0%

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?

Question 1 options

2. Which route and Cache-Control pairing is correct?

Question 2 options

3. You deploy the same repo to Fly.io, and separately to Vercel. What does framework detection configure on Fly.io?

Question 3 options

4. You run next start and a server variable that exists in .env.development.local reads as undefined. Why?

Question 4 options

Flashcards

Question

What four things does `next build` produce?

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?

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?

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?

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?

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.

Continue Learning