Skip to content
beginner Phase 1 · Project Setup and File Conventions

Environment Variables

Configure secrets for server and client without leaking them.

35m
0 problems
Topic Progress 0%

NEXT_PUBLIC_ or Nothing: Deciding What the Browser May See

Every Next.js security question about configuration collapses into one mechanism: a variable whose name starts with NEXT_PUBLIC_ is inlined into the client bundle at build time — so it is public, permanently. Not "public in practice"; public by construction. The bundler takes the literal values of those references and writes them into JavaScript that anyone can download.

Variable Readable in a Client Component? Lands in .next/static/? Typical use
NEXT_PUBLIC_GA_ID yes yes analytics measurement id
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY yes yes Stripe.js publishable key
NEXT_PUBLIC_API_BASE_URL yes yes public API origin
STRIPE_SECRET_KEY no — undefined no server-side charges
DATABASE_URL no — undefined no DB connection string
SESSION_SECRET no — undefined no signing cookies/JWTs

The inlining step is static text replacement: the bundler (webpack's DefinePlugin, or Turbopack's equivalent) swaps matching expressions with literals before shipping. That has a consequence people trip over — only statically written references are replaced:

const url = process.env.NEXT_PUBLIC_API_URL        // works: replaced at build
const { NEXT_PUBLIC_API_URL } = process.env        // works, if at module scope
const key = process.env[`NEXT_PUBLIC_${NAME}`]     // client: undefined, always

A dynamic name cannot be resolved statically, so the browser receives the untouched expression and you get undefined at runtime — with no build error to warn you. The same rule explains why a value edited after next build does nothing to the client: the old literal is already inside the emitted files, and only a rebuild rewrites it.

The practical discipline is a naming ritual: secrets get no prefix and are read only on the server (Server Components, Route Handlers, next.config), while anything prefixed NEXT_PUBLIC_ is reviewed as if it were source code — because it is.

One correction people need before their first interview answer: the prefix does not restrict a variable to the browser, it adds the browser to its audience. process.env.NEXT_PUBLIC_API_URL reads perfectly well inside a Server Component or a Route Handler — server code sees the whole environment. The prefix only controls one thing: whether the value is also written into the shipped bundle. So the decision is never "server or client?", it is "is exposing this value acceptable if a visitor views source?". If the answer is no, the variable must not carry the prefix, no matter where you intend to read it.

Verify instead of trusting:

npm run build
grep -R "sk_live" .next/static && echo "LEAK: secret shipped to the browser"

That grep is the one habit worth adopting on every project. It catches the classic accident: pasting a server key into a variable that a client component happened to read, seeing it work in local testing, and shipping it. You cannot retract a built asset — reversing the mistake means rotating the key, not reverting a commit, because every visitor who loaded the page already has a copy.

The mirror image is the equally common bug: a client component reads process.env.API_URL (no prefix), gets undefined, and your fetch silently calls undefined/api/items. The failure is silent because undefined is a legal JavaScript value. The fix is deciding the prefix before writing the line — server-only by default, NEXT_PUBLIC_ only when the browser genuinely needs it. App Directory Conventions already told you which files run where; this is the variable-level expression of the same server/client split, and the next chapter covers where these values physically live.

Load Order and Restart: Which .env File Wins and When It Reloads

Next.js loads configuration from several files, and knowing the precedence order is the difference between "my change didn't work" and a ten-second fix. The lookup order is fixed, and it stops at the first file that defines the key:

Order Source Notes
1 real process.env a shell export, CI variable, or container env wins over every file
2 .env.$(NODE_ENV).local e.g. .env.production.local, .env.development.local
3 .env.local developer values; skipped entirely when NODE_ENV=test
4 .env.$(NODE_ENV) .env.development / .env.production per mode
5 .env shared defaults, usually committed

The surprise on row 1: exporting DATABASE_URL in your shell overrides everything in your .env files, which is exactly why a value "works in one terminal and not another". Row 2 explains the reverse — a teammate's .env.local silently beats the .env.production you just edited.

next dev and next build read these files once, at startup (via @next/env). Editing a .env file does not reload the dev server, so the universal debugging step after a config change is restarting npm run dev. NODE_ENV itself is set by the framework — development under next dev, production under next build — and setting it manually to something non-standard produces confusing hybrid behavior; leave it alone.

Everything that arrives from a .env file is a string, and that trips people up at the worst moment:

if (process.env.FEATURE_CHECKOUT) { enableCheckout() }   // "false" is truthy!
const port = Number(process.env.PORT)                    // undefined → NaN

FEATURE_CHECKOUT=false in your .env still enters the branch, because the string "false" is not boolean false. Validate and coerce once, at the boundary — a tiny zod schema in lib/env.ts that parses process.env and exports typed values turns a class of production-only bugs into a startup error with a readable message, and it doubles as documentation of which keys the app expects.

What to commit:

.env            # shared, non-secret defaults — tracked in git
.env.example    # key names + empty values, the documentation for new hires
.env.local      # YOUR real values — must be git-ignored
.env.production # per-environment non-secrets — tracked

The scaffold's .gitignore covers local env files, but verify before the first commit with git status --short: a .env.local that shows up in git is a leak that gets harder to remove every time someone pulls. Rotate anything that lands in history.

The failure that costs the most time is the environment mismatch: DATABASE_URL works locally, and the deployed app throws Environment variable not found: DATABASE_URL. That is because .env.local is a local file by design and never ships — production reads its own environment:

Platform How the values arrive
Vercel Project settings / vercel env pull (writes a local .env.local for you)
Docker docker run --env-file=.env.production app or ENV in the Dockerfile
Kubernetes Secret mounted as env, e.g. envFrom: secretRef
CI (GitHub Actions) repository secrets exported before npm run build

One timing detail ties this to the previous chapter: a statically prerendered page reads server variables while the build runs and bakes the result into HTML, so changing DATABASE_URL in the container afterward does not update that page — only routes rendered per request pick up runtime values. If you need runtime flexibility, you must also opt the route into dynamic rendering. And because next.config.ts is itself a Node script that runs before the build, it can read process.env too — which is where next.config Options picks the story up.

How Secrets Reach Production Without Reaching the Browser

A leaked secret and a missing secret are the two ways configuration fails in production, and they fail at different times. The timing is the whole lesson:

Variable kind When it is read Where it ends up Fails at
NEXT_PUBLIC_* next build literal text in .next/static/** build (or worse, never — it just ships)
server var, static route next build (prerender) baked into generated HTML build, or silently stale in prod
server var, dynamic route request time stays on the server first request after deploy

Three concrete failure sequences, each of which you will eventually meet:

1. Prefix mistake — fails after deploy. Someone renames STRIPE_SECRET_KEY → NEXT_PUBLIC_STRIPE_SECRET_KEY to silence an undefined in a client component. It works instantly — of course it does; the value is now in the bundle. Detection is the grep from the first chapter, run against .next/static. Remediation is a key rotation plus reverting the name. This is the error an interviewer is probing for when they ask "how do you keep secrets out of the client bundle?", and the layered answer is the strongest response:

  1. Never prefix a secret with NEXT_PUBLIC_.
  2. Keep .env.local (and any file with real values) out of git; ship an .env.example instead.
  3. Verify by grepping the build output, not by reading the code and hoping.

2. Missing at runtime — fails on the first request. You tested with .env.local, deployed with a Dockerfile that never mounts it, and the container starts fine until a page calls the DB:

Error: Environment variable not found: DATABASE_URL.

The build may even have succeeded, because a prerendered route never touched the database. Standalone images (output: 'standalone') make this likelier: the app carries its own server.js, but environment still comes from the outside — pass --env-file or orchestrator secrets. The same shape appears on Vercel when a variable is scoped to "Development" but not "Production": the page you tested at 2 a.m. worked, and the deployed URL 500s on a database call that never had a value to begin with.

3. Missing at build — fails as undefined. CI does not have NEXT_PUBLIC_API_URL when next build runs, so the client bundle contains the literal string undefined, and every request goes to undefined/api/items. Nothing throws. Fixing it means adding the variable to the CI environment and rebuilding — it is not a runtime patch, because inlining already happened.

For reads that must work in either context, keep server reads direct and client reads static:

// server component — safe, any variable
const pool = new Pool({ connectionString: process.env.DATABASE_URL })

// client component — only NEXT_PUBLIC_, static reference
const base = process.env.NEXT_PUBLIC_API_BASE_URL

Logging is an exfiltration channel too: printing process.env in a Route Handler during a debug session writes every production secret into your log aggregation, where it stays searchable by anyone with dashboard access.

There is a legacy escape hatch worth recognizing rather than using: the env key in next.config.ts, which the Next.js docs now label a legacy API, no longer recommended. It inlines values into the client bundle just like the prefix does, so it hides the exposure rather than removing it. Prefer the prefix, and you will describe configuration the way the framework intends — which is also where the next topic, next.config Options, begins: a config file that reads process.env at build time and decides what the server does with it.

Quiz

1. A Client Component runs `const key = process.env[`NEXT_PUBLIC_${NAME}`]`. What does it get?

Question 1 options

2. Both `.env` and `.env.local` define `NEXT_PUBLIC_API_URL`, and your shell also exports it. Which value does the app see?

Question 2 options

3. The app runs locally but production throws `Environment variable not found: DATABASE_URL.` What is the most likely cause?

Question 3 options

4. CI runs `next build` without NEXT_PUBLIC_API_URL set. What does the shipped client code contain?

Question 4 options

Flashcards

Question

What exactly does the NEXT_PUBLIC_ prefix do?

Answer

It marks the variable for inlining: at build time the bundler replaces every static `process.env.NEXT_PUBLIC_X` reference with its literal value in client JavaScript, making it publicly readable. It also remains readable on the server — the prefix adds exposure, it does not remove access.

Question

What is the .env load order in Next.js?

Answer

process.env (real environment) first, then .env.$(NODE_ENV).local, .env.local (skipped under NODE_ENV=test), .env.$(NODE_ENV), and .env last — lookup stops at the first file that defines the key, and files are loaded once at dev/build startup, so edits need a restart.

Question

When is a server-only variable read: at build or at request?

Answer

Both, depending on the route. Statically prerendered routes read it during `next build` and bake the result into HTML, so runtime changes have no effect; dynamically rendered routes read it per request from the process environment.

Question

How do you prove a secret did not ship to the browser?

Answer

Build, then grep the client output: `grep -R "sk_live" .next/static` (search for the actual value, not the key name). If it matches, the fix is rotating the key — reverting the commit cannot unsend bytes visitors already downloaded.

Question

How are environment variables provided on Vercel, Docker, and Kubernetes?

Answer

Vercel: project settings or `vercel env pull` (writes .env.local). Docker: `docker run --env-file` or ENV. Kubernetes: a Secret mounted via envFrom. In every case the process environment wins over .env files, and for static routes the values must exist at build time.

Revision Notes

Key Takeaways

  • 1. NEXT_PUBLIC_ means public forever: the value is written into .next/static as literal text, so hiding it later requires rotating the credential, not reverting a commit
  • 2. Only static references are inlined — dynamic `process.env[name]` on the client resolves to undefined with no error, which is why the prefix decision belongs before the line of code
  • 3. Load order is process.env → .env.$(NODE_ENV).local → .env.local → .env.$(NODE_ENV) → .env, and files load once at startup, so restart after editing
  • 4. All env values are strings: `FEATURE_FLAG=false` is truthy, and parsing/validating once in lib/env.ts converts config mistakes into startup errors
  • 5. Failure timing is the diagnostic: NEXT_PUBLIC missing in CI fails silently at build, server vars in static routes freeze at prerender, server vars on dynamic routes fail at the first request

Interview Tips

  • • "How do you keep a secret out of the client bundle?" — give the three layers: no NEXT_PUBLIC_ on secrets, .env.local git-ignored with .env.example committed, and verification by grepping .next/static; the grep is the detail that separates an answer from a policy
  • • "Can a client component read DATABASE_URL?" — no: it is not in the bundle, so the expression evaluates to undefined; then explain that the prefix adds the browser as an audience rather than restricting a variable to the server
  • • "Our env var works locally but not in production — walk me through it" — .env.local never ships, process.env beats files, check platform scoping (Dev vs Prod), and note static routes baked their values at build time
  • • "What does env in next.config.ts do?" — recognize it as a legacy, no-longer-recommended API that inlines values into the client bundle exactly like the prefix, then say you would use NEXT_PUBLIC_ so exposure stays visible in the name

Cheat Sheet

Environment Variables

The one rule: anything named NEXT_PUBLIC_* is inlined into the client bundle at build time → public forever. No prefix → server only; in a Client Component it reads as undefined.

Exposure matrix:

Client Component .next/static/ Server code
NEXT_PUBLIC_X ✅ ✅ (literal) ✅
X (no prefix) ❌ undefined ❌ ✅

Load order (first match wins):

process.env  >  .env.$(NODE_ENV).local  >  .env.local  >  .env.$(NODE_ENV)  >  .env
  • Files load once at next dev / next build startup → restart after editing.
  • NODE_ENV is set by Next (development/production); don't override it.
  • test mode skips .env.local.

Commit policy:

.env            # tracked, non-secret defaults
.env.example    # tracked, key names + empty values
.env.local      # NEVER tracked — real values (verify: git status --short)

Static references only (client side):

process.env.NEXT_PUBLIC_API_URL        // ✅ replaced at build
const { NEXT_PUBLIC_API_URL } = process.env  // ✅ at module scope
process.env[`NEXT_PUBLIC_${name}`]     // ❌ undefined — not statically resolvable

Timing of reads:

Kind Read Deploy failure
NEXT_PUBLIC_* build missing in CI → literal undefined shipped
server var + static route build (prerender) changing it in prod has no effect
server var + dynamic route request missing → Environment variable not found: DATABASE_URL.

Leak check after every build:

grep -R "sk_live" .next/static && echo "LEAK"

A shipped secret is fixed by rotating the key, not by reverting the commit.

Three-layer answer to "how do you prevent leakage?": no NEXT_PUBLIC_ on secrets · .env.local git-ignored with .env.example committed · verify server vs client context plus a grep of the build output.

Continue Learning

Before You Start

Next Topics

Related Reading