Skip to content

Environment Variables

The topic that produces the most real production incidents, and a reliable interview question.


1. The Two Kinds

Server-onlyClient-exposed
PrefixnoneNEXT_PUBLIC_ (Next.js), VITE_ (Vite)
Available inServer Components, Route Handlers, Server Actions, proxy.tsEverywhere, including the browser
Read atRuntimeBuild time — inlined into the bundle
Safe for secretsYesNever
Change takes effectOn restart / next requestOnly after a rebuild

The Rule

Anything prefixed NEXT_PUBLIC_ is permanently public. It is literally substituted into the JavaScript that ships to the browser. Anyone can open DevTools and read it.

js
// After build, this line in your bundle is literally:
const url = "https://api.example.com";   // not process.env.NEXT_PUBLIC_API_URL

2. The Most Common Production Mistake

bash
NEXT_PUBLIC_STRIPE_SECRET_KEY=sk_live_...   # ❌ catastrophic

The developer prefixed it because "it was undefined in the component". The secret key is now in the public bundle, scrapeable by anyone.

What Should Have Happened

The component needed data, not the key. Move the call to the server:

tsx
// ✅ Server Component or Route Handler — key never leaves the server
async function Checkout() {
  const session = await stripe.checkout.sessions.create(…);   // uses STRIPE_SECRET_KEY
  return <CheckoutButton url={session.url} />;
}

"It was undefined in my component" means the code is in the wrong place, not that the variable needs a prefix. That sentence is a strong interview answer.

Which Keys Are Meant To Be Public

Some genuinely are — Stripe's publishable key (pk_...), a Google Maps browser key, a PostHog project key. These are designed to be public and are restricted by domain or scope on the provider's side. Prefixing those is correct.

The test: does the provider's documentation call it publishable or public? If it says secret, it never gets a prefix.


3. The Build-Time Inlining Trap

1. Set NEXT_PUBLIC_API_URL = https://old-api.com
2. Deploy
3. Change it to https://new-api.com in the dashboard
4. Nothing happens

The old value is baked into the deployed JavaScript. You must redeploy.

This confuses people constantly, because server-side variables do pick up changes. The two behave differently and the difference is invisible until it bites.

Rule of Thumb

Changed a NEXT_PUBLIC_ variable → redeploy. Changed a server variable → a redeploy or restart is enough; on Vercel the next invocation picks it up.


4. Vercel's Three Scopes

ScopeApplies to
ProductionThe production domain, main branch
PreviewEvery PR and branch deployment
Developmentvercel dev and vercel env pull

A variable set only for Production is undefined in preview deployments — a very common "works in prod, broken in the PR preview" cause.

Preview Must Not Point at Production

Production:  DATABASE_URL = postgres://prod…
Preview:     DATABASE_URL = postgres://staging…
Development: DATABASE_URL = postgres://localhost…

A preview deployment is a fully working app. If it points at the production database, a reviewer clicking "Delete" deletes a real record. Same for Stripe: test keys in Preview, live keys in Production only.

Pulling Them Locally

bash
vercel env pull .env.local

Writes the Development-scope variables into .env.local, so your machine matches the dashboard.


5. Local Files and Precedence

.env.local          # your machine, gitignored — highest priority
.env.development    # dev defaults, committed
.env.production     # production defaults, committed
.env                # shared defaults, committed

Only .env.local should hold secrets, and it must be gitignored.

bash
# .gitignore
.env*.local
.env

Commit a .env.example with the keys and dummy values so a new developer knows what to set:

bash
# .env.example
DATABASE_URL="postgres://user:pass@localhost:5432/dev"
JWT_SECRET="replace-with-32-char-random-string"
NEXT_PUBLIC_API_URL="http://localhost:3000"

6. Validate at Startup

process.env.X is always string | undefined. A missing secret should crash the boot, not the first user's request.

ts
// lib/env.ts
import { z } from "zod";

const envSchema = z.object({
  NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
  DATABASE_URL: z.string().url(),
  JWT_SECRET: z.string().min(32),
  STRIPE_SECRET_KEY: z.string().startsWith("sk_"),
  NEXT_PUBLIC_API_URL: z.string().url(),
});

export const env = envSchema.parse(process.env);
ts
import { env } from "@/lib/env";

env.DATABASE_URL;   // string, guaranteed present and a valid URL

Why This Is Worth Doing

Without it, a typo in a variable name surfaces as undefined deep in a request three days later. With it, the build or boot fails immediately with a clear message naming the missing variable.

"Fail fast at startup" is the phrase. It's a small amount of code that turns a 3am incident into a failed deploy.

Important Next.js Caveat

Client-side code cannot iterate process.env — the bundler only replaces the exact literal expressions it can see. Reference each NEXT_PUBLIC_ variable explicitly:

ts
// ✅ Works — literal reference, replaced at build
const url = process.env.NEXT_PUBLIC_API_URL;

// ❌ Undefined in the browser — nothing to statically replace
const key = "NEXT_PUBLIC_API_URL";
const url = process.env[key];

So validate the client-exposed variables in a separate schema that references them literally, and validate server variables only in server code.


7. Secret Rotation

When To Rotate

  • A secret was committed to git, ever
  • Someone with access left the team
  • A vendor reports a breach
  • On a schedule, for high-value credentials

If a Secret Was Committed

Rotate first. Clean history second.

  1. Generate a new secret and deploy it. This is step one
  2. Revoke the old one at the provider
  3. Then purge git history (git filter-repo or BFG) and force push
  4. Assume it was already scraped — public repos are scanned by bots within minutes of a push

Removing the line in a follow-up commit does nothing: the value is still in history, in every clone, and in every fork. GitHub's secret scanning may have already notified the provider, which is a good outcome, not a bad one.

Candidates who say "I'd remove it from the repo" without rotating have given the wrong answer.


8. Checklist

  • [ ] No secret carries a NEXT_PUBLIC_ / VITE_ prefix
  • [ ] .env* gitignored; .env.example committed with dummy values
  • [ ] Variables set for all three Vercel scopes, not just Production
  • [ ] Preview uses a staging database and test API keys
  • [ ] Validated at startup with Zod
  • [ ] Different values per environment — no shared secrets between staging and production
  • [ ] Redeploy triggered after changing any NEXT_PUBLIC_ variable
  • [ ] Secrets redacted in logging configuration
  • [ ] Rotation plan for departures and suspected leaks

9. Debugging

SymptomCause
undefined in a Client ComponentMissing NEXT_PUBLIC_ prefix
undefined in a preview deploymentSet for Production scope only
Changed the value, nothing happenedNEXT_PUBLIC_ is build-time — redeploy
Works locally, fails deployedSet in .env.local but never added to the dashboard
undefined at build but fine at runtimeReferenced during the build phase without being available then
A secret is visible in the browser bundleIt has a public prefix — rotate it now

Quick Check

bash
npm run build
grep -r "sk_live" .next/static/    # should return nothing

Grepping the built client bundle for a known secret prefix is a fast, concrete way to prove nothing leaked. Worth adding as a CI step for any project handling payment or auth credentials.

© 2025 DDocs · Dipak's Documentation Guide