Environment Variables
The topic that produces the most real production incidents, and a reliable interview question.
1. The Two Kinds
| Server-only | Client-exposed | |
|---|---|---|
| Prefix | none | NEXT_PUBLIC_ (Next.js), VITE_ (Vite) |
| Available in | Server Components, Route Handlers, Server Actions, proxy.ts | Everywhere, including the browser |
| Read at | Runtime | Build time — inlined into the bundle |
| Safe for secrets | Yes | Never |
| Change takes effect | On restart / next request | Only 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.
// After build, this line in your bundle is literally:
const url = "https://api.example.com"; // not process.env.NEXT_PUBLIC_API_URL2. The Most Common Production Mistake
NEXT_PUBLIC_STRIPE_SECRET_KEY=sk_live_... # ❌ catastrophicThe 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:
// ✅ 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 happensThe 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
| Scope | Applies to |
|---|---|
| Production | The production domain, main branch |
| Preview | Every PR and branch deployment |
| Development | vercel 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
vercel env pull .env.localWrites 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, committedOnly .env.local should hold secrets, and it must be gitignored.
# .gitignore
.env*.local
.envCommit a .env.example with the keys and dummy values so a new developer knows what to set:
# .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.
// 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);import { env } from "@/lib/env";
env.DATABASE_URL; // string, guaranteed present and a valid URLWhy 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:
// ✅ 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.
- Generate a new secret and deploy it. This is step one
- Revoke the old one at the provider
- Then purge git history (
git filter-repoor BFG) and force push - 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.examplecommitted 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
| Symptom | Cause |
|---|---|
undefined in a Client Component | Missing NEXT_PUBLIC_ prefix |
undefined in a preview deployment | Set for Production scope only |
| Changed the value, nothing happened | NEXT_PUBLIC_ is build-time — redeploy |
| Works locally, fails deployed | Set in .env.local but never added to the dashboard |
undefined at build but fine at runtime | Referenced during the build phase without being available then |
| A secret is visible in the browser bundle | It has a public prefix — rotate it now |
Quick Check
npm run build
grep -r "sk_live" .next/static/ # should return nothingGrepping 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.