Skip to content

Lesson 96·M10 App Router

Env Vars, Build & Deployment

Open

Learning Objectives

By the end of this lesson you should be able to:

  • Explain what NEXT_PUBLIC_ does and why a secret leaks into the client bundle
  • Order the .env files Next.js reads, and pick the right one per environment
  • Walk through the build pipeline: build, static vs dynamic output, start
  • Say how Vercel builds and serves a Next.js app (and how ISR stays alive)
  • Debug the "secret in the bundle" and "works locally, not in prod" failure modes

What Is This Lesson Really About?

Environment variables are a build-time contract: only NEXT_PUBLIC_-prefixed values get inlined into the client bundle, and the build/start pipeline decides which routes are static files and which render per request.

Two systems sit side by side. The env-var system decides who is allowed to see a value — the server process, or the browser. The build system decides when work happens — at deploy time into static files, or per request. Confusing either one produces the classic incidents.

Mental Model

Think of process.env as a sticky note wall that only the server can see.

During next build, Next.js walks your code. Any process.env.SECRET it finds is replaced by the value from the build machine — still server-side, safe. But when a module is bundled for the client, the same replacement happens to whatever it references. NEXT_PUBLIC_ is the prefix that says "this one is for the browser too": its value gets baked into the JS file that ships to users, forever, as plain text. Server-only values referenced from client code are replaced with undefined — silently, at build time.

Visual Flow

   .env files (build time, on the build machine)
        │
        ▼
   next build ── compiles every module ──┬── server bundle
                                         │      process.env.SECRET → "s3cr3t"  (safe)
                                         │      process.env.API_URL → "https://…"
                                         │
                                         └── client bundle  (sent to browsers)
                                                NEXT_PUBLIC_ANALYTICS → "G-1234"
                                                SECRET               → undefined  ← the leak would be HERE
        │
        ▼
   .next/ ── static routes (HTML/JS files)     dynamic routes (per-request)
   ──────────────────────────────────          ─────────────────────────────
   /about → about.html                          /dashboard (reads cookies)
   /       → index.html                         /products/[id] (ISR, revalidated)
        │
        ▼
   next start / Vercel serves .next/  →  browser

How It Works — Env Vars

Next.js loads .env files at build time and exposes them through process.env. The prefix decides the audience:

  • No prefix — server-only. Available in Server Components, Route Handlers, Server Actions, and next.config.ts. Never in client code.
  • NEXT_PUBLIC_ — inlined into the client bundle and available server-side. The value is baked in at build time and shipped to every browser.
// server-only — safe anywhere on the server:
const dbUrl = process.env.DATABASE_URL;
// client-safe — this one is intentionally public:
const analyticsId = process.env.NEXT_PUBLIC_ANALYTICS_ID;
process.env.DATABASE_URL           → "postgres://…"      (server bundle only)
process.env.NEXT_PUBLIC_ANALYTICS_ID → "G-1234"          (inlined into client JS)
process.env.SUPABASE_SERVICE_KEY   → undefined in client JS (server-only, not inlined)

Pitfall

NEXT_PUBLIC_ is not a way to make a secret "available to the client safely". It's a way to make a value public. Anything behind that prefix is readable by anyone who opens DevTools. Put a service key behind it and you've shipped the key — the classic leak.

The leak, concretely. This code compiles and deploys — and leaks the key:

// lib/client-config.ts — imported by a Client Component
export const SUPABASE_KEY = process.env.SUPABASE_SERVICE_KEY;
The client bundle now literally contains:
  "SUPABASE_SERVICE_KEY": undefined   ← at build time the value was not inlined

and its inverse, with NEXT_PUBLIC_:

export const SUPABASE_KEY = process.env.NEXT_PUBLIC_SUPABASE_SERVICE_KEY;
The client bundle now contains the real key, in plain text, for anyone to read:
  var SUPABASE_KEY = "sb_secret_7x…";

The first leaks nothing (it's undefined in the browser — but then the feature silently breaks), the second leaks everything. Both are the same mistake: putting a server secret on a path that reaches client code.

File loading order. Next.js reads, with later files overriding earlier ones:

1. .env.$(NODE_ENV).local     → .env.development.local / .env.production.local
2. .env.local                 → never committed, highest priority after (1)
3. .env.$(NODE_ENV)           → .env.development / .env.production
4. .env                       → defaults, committed

.env.local (and .env.*.local) should be gitignored — that's where real secrets live on your machine. Note the subtlety: only NODE_ENV-specific .local files load in the corresponding mode; plain .env.local loads in every mode, which is why a prod secret sitting in .env.local can sneak into a local prod build.

Also true:

  • next dev does not load .env.production — it loads .env.development*.
  • Changing an env var requires a rebuild (inlining happens at build time) and a dev-server restart.
  • Runtime-only values in Node middleware/Route Handlers can read process.env at runtime too — but anything that reaches the client is still inlined.

How It Works — The Build Pipeline

next build produces a .next/ directory; next start (or a platform) serves it.

npm run build
▲ Compiling /  Generating static pages (8/8) ...
   ✓ Generating static pages
   ✓ Collecting page data
   ✓ Finalizing page optimization

Route (app)                              Size     First Load JS
┌ ○ /                                    1.2 kB          78 kB
├ ○ /about                               1.1 kB          78 kB
├ ƒ /dashboard                           1.3 kB          79 kB
└ ƒ /products/[id]                      1.4 kB          79 kB
○  (Static)   prerendered as static content
ƒ  (Dynamic)  server-rendered on demand

Each route ends up in exactly one bucket:

MarkerMeaningWhen
○ StaticPrerendered to an HTML file at build timeNo request data read; can be cached aggressively
ƒ DynamicRendered per requestReads cookies()/headers()/params, or no-store fetches
● ISRStatic + revalidatedrevalidate set, or on-demand revalidation (Lesson 91)

next start serves .next/ on a Node server. For a standalone, single-process container you can set output: 'standalone' in next.config.ts, which produces a minimal node_modules + server folder you can Dockerise — a common senior answer.

// next.config.ts
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
  output: 'standalone',   // minimal self-contained server for containers
};

export default nextConfig;

How It Works — Deploying on Vercel

Vercel is the reference deployment: it runs next build for you and serves the result on their edge network.

  git push → Vercel: install deps → next build → serve .next/ from edge network
                                                    │
              ┌─────────────────────────────────────┼────────────────────────┐
              ▼                                     ▼                        ▼
         static HTML files                  dynamic routes                  ISR pages
         (CDN cache)                        (serverless per request)        (revalidated
                                                                             on schedule/
                                                                             on demand)
  • Env vars live in the project dashboard — set for Preview and Production separately. Changing a dashboard value does not touch an existing build; you must redeploy, because the value was inlined at build time.
  • Preview deploys get a unique URL per branch/PR — test the real thing before merging.
  • ISR works because Vercel keeps the functions and cache warm — revalidation and on-demand invalidation (Lesson 91) both survive deployment.
  • The build is stateless and reproducible: anything you need at build time (env vars, content) must be available to the build environment.

Interview Explanation

Env vars are a build-time contract. Server-only variables are replaced in the server bundle; NEXT_PUBLIC_-prefixed ones are inlined into the client bundle at build time — that's exactly why a secret leaks: someone put a server key behind NEXT_PUBLIC_, or referenced a server-only key from client code and shipped it. The rule is: nothing sensitive ever reaches a client module; NEXT_PUBLIC_ is for public config only.

The build produces static routes (prerendered HTML, ○), dynamic routes (per request, ƒ), and ISR (●). next start or a platform like Vercel serves .next/. Vercel runs the build, keeps env vars per environment, and maintains the ISR cache.

Senior-Level Insights

  • The leak question is a boundary question. "Why is my secret in the bundle?" is almost always the server/client boundary from Lesson 88, observed through env vars. The fix is structural — keep secrets server-side and never let a client module reference them — not "rename the variable".
  • Treat NEXT_PUBLIC_ as an API for public configuration — analytics IDs, API base URLs for public endpoints, feature flags you're fine sharing. Audit the git history: once inlined, a leaked value is in every deployed bundle and must be rotated, not deleted.
  • Validate env vars at startup (zod + a schema, or t3-env) so a missing DATABASE_URL fails the build loudly instead of crashing in production.
  • output: 'standalone' is the standard answer for Docker/self-hosting — a tiny, production-ready server without node_modules bloat.
  • Preview environments are the deployment killer feature. Mentioning that you test env var sets per environment (development/preview/production) separates shipped experience from tutorial knowledge.

Common Mistakes

❌ A secret behind NEXT_PUBLIC_ — the classic leak, shipped to every browser:

process.env.NEXT_PUBLIC_STRIPE_SECRET_KEY  // 💥 anyone can read the Stripe key

❌ Reading a server-only env var from client code — silently becomes undefined in the browser, so the bug hides until production:

// client.tsx
const key = process.env.OPENAI_API_KEY;   // undefined in the bundle — feature breaks

❌ Expecting runtime changes without a rebuild: editing .env in the dashboard and not redeploying, or editing .env.local while the dev server runs.

❌ Committing .env.local (or worse, a .env full of prod secrets) to git.

❌ Forgetting that next dev doesn't load .env.production — a prod-only bug that never shows up locally.

Best Practices

✅ Prefix only public config with NEXT_PUBLIC_ — nothing else

✅ Keep server secrets in non-prefixed vars, referenced from server code only

✅ Gitignore .env*.local; commit only safe defaults in .env

✅ Validate required env vars at startup (zod / t3-env)

✅ Set env vars in the platform per environment; redeploy after changing them

✅ Use output: 'standalone' for Docker / self-hosted deploys

❌ Don't put credentials in client modules — not even "temporarily"

❌ Don't store secrets in .env committed to the repo

Interview Questions

Q1. What does the NEXT_PUBLIC_ prefix do?

It marks an env var as public and inlines its value into the client bundle at build time. Server-only vars without the prefix are available to server code but become undefined in the browser. Anything with the prefix is readable by anyone who opens the shipped JS — so it must only ever hold public config.

Q2. How did a secret end up in the client bundle?

Two ways. Someone referenced a server-only var from client code — then it's not really a leak, the bundle just contains undefined, which breaks the feature silently. Or someone put a secret behind NEXT_PUBLIC_, and the real value was baked into the JS at build time and shipped to everyone. The second is the actual leak; both come from ignoring the server/client boundary.

Q3. What's the difference between static and dynamic routes?

A static route (○) is fully prerendered to an HTML file at build time — no request data involved — so it's served from cache almost for free. A dynamic route (ƒ) renders per request because it reads cookies, headers, or params. ISR (●) is the middle: static output revalidated on a schedule or on demand.

Q4. Walk me through deploying a Next.js app to Vercel.

Push to git. Vercel runs next build in the cloud, with env vars from the project dashboard applied per environment. The output is served from their edge network: static files from the CDN, dynamic routes as serverless functions, ISR pages with the cache maintained by the platform. Preview deploys get a unique URL for each branch.

Q5. Why is my env var undefined in the browser?

It doesn't have the NEXT_PUBLIC_ prefix, so it was never inlined into the client bundle — by design. Either read it on the server and pass the value to the client explicitly, or, if it's genuinely public config, rename it with the prefix and redeploy.

Senior follow-up: How do you handle secrets in a Next.js monorepo?

One shared validated env schema, loaded in a single server-only module. Client components import a tiny public-config.ts that only re-exports NEXT_PUBLIC_ values. CI runs the schema validation with a placeholder env file so missing vars fail the build, and real secrets live in the platform, never in the repo. Rotate anything that was ever inlined.

Follow-up Questions

Does .env.production load in development?

No. next dev loads .env.development*; next build/next start load .env.production*. That split is exactly why "works in dev, broken in prod" incidents happen — the value sets are different.

How do you test env var changes locally?

Edit .env.local, restart the dev server, and for anything NEXT_PUBLIC_ remember it's inlined at build time — a production preview deploy is the real test.

What's output: 'standalone'?

A build mode that emits a minimal, self-contained server — only the code and dependencies the app needs — which you can drop into a Docker image without shipping node_modules or source.

Comparison Table

Server-only (DATABASE_URL)NEXT_PUBLIC_ (NEXT_PUBLIC_ANALYTICS_ID)
Server bundle✅ real value✅ real value
Client bundle❌ replaced with undefined✅ inlined, visible to everyone
When it's setbuild time (inlined) / runtime (Node)build time only
Safe forsecrets, DB URLs, keyspublic config: IDs, public API URLs, flags
Change requiresrebuild + restartrebuild (new deploy)
Static (○)Dynamic (ƒ)ISR (●)
Renderedat buildper requestat build + revalidated
CachedCDNper requestCDN + revalidation
Examplemarketing pagesdashboards with cookiesproduct pages (Lesson 91)

Code Example

A complete build-time flow, from .env to the bundle — first the environment, then the build output shape:

# .env.local (gitignored)
DATABASE_URL=postgres://user:pass@prod-db.example.com/acme
NEXT_PUBLIC_API_URL=https://api.acme.com
// app/page.tsx — server component
export default async function Home() {
  const dbUrl = process.env.DATABASE_URL;            // server-only ✅
  return <main>{dbUrl ? 'db configured' : 'no db'}</main>;
}
// components/analytics.tsx — 'use client'
export function Analytics() {
  return <script src={`${process.env.NEXT_PUBLIC_API_URL}/track.js`} />;
}

After next build, the two bundles contain different things:

server bundle (app/page.js):
  var DATABASE_URL = "postgres://user:pass@prod-db.example.com/acme";   ✅ present

client bundle (components/analytics.js — shipped to browsers):
  var NEXT_PUBLIC_API_URL = "https://api.acme.com";                     ✅ present (public)
  // DATABASE_URL is not here at all — server-only                      ✅ absent

If someone "helpfully" changed the analytics line to a server-only key, the bundle would show undefined — and if they prefixed a secret with NEXT_PUBLIC_, the bundle would show the secret. That contrast is the whole lesson.

Performance Notes

  • Static output is the cheapest request there is — a file served from cache. Routes that don't need request data should stay static (○). Reading cookies/headers moves them to ƒ, so place those reads as low as possible (Lesson 95).
  • ISR (●) is the deploy-time optimisation: build once, serve cached, revalidate in the background. The cost is the revalidation pass, not per-request rendering.
  • Env inlining is compile-time substitution — NEXT_PUBLIC_ values add no runtime overhead, but they do add bundle presence: a secret inlined is permanently public.
  • output: 'standalone' shrinks deploy size dramatically; it matters most for Docker image size and cold-start times on serverless.

Debugging Scenarios

SymptomCauseFix
Secret found in the client bundleNEXT_PUBLIC_ on a secret, or server-only key reached client codeRemove the prefix / restructure; rotate the key — it's public now
process.env.X is undefined in browserX has no NEXT_PUBLIC_ prefixPass the value server→client, or rename + redeploy if public
"Works in dev, fails in prod"Dev loads .env.development*, prod loads .env.production*Compare the two sets; add a startup validation
Dashboard env change has no effectValue was inlined at build timeRedeploy (a new build picks up the new value)
.env.local edit has no effectDev server caches env at startRestart next dev

Quick Revision Notes

  • NEXT_PUBLIC_ = inlined into the client bundle at build time — public, permanent
  • No prefix = server-only; undefined in the browser
  • Loading order: .env.$(NODE_ENV).local → .env.local → .env.$(NODE_ENV) → .env
  • next build → static (○) vs dynamic (ƒ) vs ISR (●); next start serves .next/
  • Vercel: git push → build → serve from edge; env vars per environment, redeploy to apply
  • output: 'standalone' for Docker / self-hosted
  • A leaked inlined secret must be rotated, not just deleted

Cheat Sheet

Env vars:
  DATABASE_URL=…              → server bundle only
  NEXT_PUBLIC_ANALYTICS_ID=…  → inlined into client bundle (public)
  .env.production.local > .env.local > .env.production > .env

Build:
  npm run build          → .next/
  next start             → serve .next/
  output: 'standalone'   → minimal server for containers

Route markers:
  ○ Static (prerendered)  ƒ Dynamic (per request)  ● ISR (revalidated)

Vercel:
  git push → build → serve from edge
  env vars per environment; change ⇒ redeploy (values are inlined at build)

Key Takeaways

Recap

  • NEXT_PUBLIC_ is build-time inlining into the client bundle — a prefix for public config, never for secrets
  • Server-only vars reach the browser as undefined, which is the silent version of the same boundary bug
  • .env precedence: .env.$(NODE_ENV).local > .env.local > .env.$(NODE_ENV) > .env
  • next build classifies every route: static (○), dynamic (ƒ), or ISR (●)
  • next start serves .next/; output: 'standalone' gives a minimal container server
  • Vercel builds for you and keeps env vars per environment — changing one means redeploying
  • A secret that was ever inlined is public forever — rotate it

Check your understanding

Answer each one out loud. Then rate yourself honestly.

0/7
1

What does NEXT_PUBLIC_ do — and what should never have the prefix?

2

Name the two ways a secret can end up in a client bundle.

3

Order the .env files by precedence.

4

Why does changing an env var in the Vercel dashboard not change an existing deployment?

5

What do the ○, ƒ and ● markers mean in a build output?

6

Which env files does next dev load, and why does that cause "works locally" bugs?

7

What does output: 'standalone' do, and when would you use it?

What's Next

Lesson 97 — Top JavaScript Interview Questions. The first rehearsal lesson. You've built the foundations — now learn to deliver them under pressure, starting with the JavaScript questions that open most screens.