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