Environment Variables in Next.js
Keeping Secrets Secret
LinkedIn Hook
"Your Stripe secret key just got pushed to production. It's sitting in the JavaScript bundle that every visitor downloads."
This is the single most common security mistake I see in Next.js code reviews. A developer adds
STRIPE_SECRET_KEYto.env.local, references it in a component that happens to render on the client, and ships it. The key is now public — searchable in DevTools, scraped by bots within hours, and billed to a stranger's wallet by the end of the week.The culprit? A misunderstanding of how Next.js handles environment variables. Next.js has two completely different rules for server and client env vars, separated by a single prefix:
NEXT_PUBLIC_. Get the prefix wrong and your secret is no longer a secret — it's inlined at build time directly into.jschunks.In Lesson 8.2, I break down the full env var system:
.envfile load order, theNEXT_PUBLIC_prefix rule, build-time vs runtime env, how to validate env with zod/t3-env, and the three-line pattern that guarantees secrets never leak to the client.Read the full lesson -> [link]
#NextJS #WebSecurity #EnvironmentVariables #DevOps #Vercel #InterviewPrep
What You'll Learn
- The four
.envfiles Next.js recognizes and their exact load order - Why the
NEXT_PUBLIC_prefix is the single most important rule in Next.js security - How Next.js inlines public env vars at build time (and why this matters)
- The difference between build-time and runtime environment variables
- How
process.envbehaves differently in server components vs client components - How to validate env vars with zod /
@t3-oss/env-nextjs - The five most common security mistakes that leak secrets to the client bundle
- How Vercel environment variables map to Preview, Production, and Development
- How tree-shaking removes dead server-only branches from client bundles
The Hotel Key Card Analogy — Why Two Classes of Secrets Exist
Imagine a hotel. When you check in, the front desk hands you a plastic key card. That card opens your room, the gym, and the pool. It's yours — you can show it to anyone, drop it in the lobby, lose it on the beach. The worst that happens is someone gets into your room (and even that requires physical proximity).
Now imagine the hotel also has a master key kept behind the front desk. That master key opens every single room, the safe, the manager's office, and the payroll computer. The front desk staff will use it to let you back into your room if you lose your card — but they will never hand it to you. It doesn't leave the back office. Ever.
Your Next.js application has exactly the same two classes of secrets:
- Public keys (the plastic card): API URLs, analytics tracking IDs, publishable Stripe keys, feature flags. These are safe to ship to every browser. You mark them with
NEXT_PUBLIC_, and Next.js inlines them directly into the JavaScript bundle. - Private keys (the master key): database passwords, Stripe secret keys, JWT signing secrets, OAuth client secrets, admin API tokens. These must never leave the server. Next.js guarantees they stay in server-only code — but only if you follow the rules.
The tragedy of leaked secrets is almost always the same story: a developer treated the master key like a plastic card. They added a NEXT_PUBLIC_ prefix to a secret "just to make the error go away," or they used a secret inside a component that happened to be a client component. One line of code, one second of inattention, and the master key is now plastic.
+---------------------------------------------------------------+
| TWO CLASSES OF SECRETS IN NEXT.JS |
+---------------------------------------------------------------+
| |
| PUBLIC (plastic key card) |
| +---------------------------------------------------+ |
| | NEXT_PUBLIC_API_URL = https://api.site.com | |
| | NEXT_PUBLIC_STRIPE_PK = pk_live_... | |
| | NEXT_PUBLIC_ANALYTICS_ID = G-XYZ123 | |
| +---------------------------------------------------+ |
| Inlined into JS bundle. Visible in DevTools. OK. |
| |
| PRIVATE (master key — stays at the front desk) |
| +---------------------------------------------------+ |
| | DATABASE_URL = postgres://user:pw@...| |
| | STRIPE_SECRET_KEY = sk_live_... | |
| | JWT_SECRET = 64-char-random | |
| | OPENAI_API_KEY = sk-proj-... | |
| +---------------------------------------------------+ |
| Server only. Never inlined. Never shipped. Ever. |
| |
+---------------------------------------------------------------+
Napkin AI Visual Prompt: "Dark gradient (#0a0e1a -> #111827). Two parallel columns. LEFT column labeled 'NEXT_PUBLIC_' in emerald green (#10b981) shows a plastic hotel key card floating above a browser window with DevTools open, exposing the value — a green checkmark indicates this is safe. RIGHT column labeled 'SERVER ONLY' in purple (#8b5cf6) shows a brass master key inside a locked vault with a server rack behind it — a red X blocks any arrow from the vault to the browser. Connecting arrow from a .env.local file at the top feeds both columns. White monospace labels throughout."
The Four .env Files — Load Order and Precedence
Next.js automatically loads environment variables from up to four files. The filename determines when the values apply and which other files override them. Knowing the order by heart is interview-essential.
+---------------------------------------------------------------+
| .env LOAD ORDER (highest -> lowest priority) |
+---------------------------------------------------------------+
| |
| 1. process.env (already set in the OS / CI) |
| 2. .env.$(NODE_ENV).local (.env.development.local, etc.) |
| 3. .env.local (skipped when NODE_ENV=test) |
| 4. .env.$(NODE_ENV) (.env.development, .env.production)|
| 5. .env (defaults for everyone) |
| |
| Higher entries win. First defined value is final. |
| |
+---------------------------------------------------------------+
What Each File Is For
.env— Committed to git. Safe defaults that every developer and every deploy shares. Think:NEXT_PUBLIC_SITE_NAME=Acme..env.local— Never committed (gitignored by default). Your personal secrets on your machine: local DB password, personal API keys. This is where most developers put secrets during development..env.development/.env.production— Committed. Values that differ between dev and prod but aren't secret (e.g., different analytics IDs, different public API URLs)..env.development.local/.env.production.local— Not committed. Secret overrides scoped to one specific environment.
The Golden Rule
Anything ending in .local is gitignored. Anything without .local is committed. Put secrets only in .local files — and put them only on servers that actually need them (production secrets go in Vercel's env UI, not in a file on your laptop).
A Concrete Example
# .env (committed — safe defaults)
NEXT_PUBLIC_SITE_NAME=Acme
NEXT_PUBLIC_SUPPORT_EMAIL=hello@acme.com
# .env.development (committed — dev-specific public values)
NEXT_PUBLIC_API_URL=http://localhost:4000
NEXT_PUBLIC_STRIPE_PK=pk_test_51Abc...
# .env.production (committed — prod-specific public values)
NEXT_PUBLIC_API_URL=https://api.acme.com
NEXT_PUBLIC_STRIPE_PK=pk_live_51Xyz...
# .env.local (gitignored — your personal dev secrets)
DATABASE_URL=postgres://me:pw@localhost:5432/acme
STRIPE_SECRET_KEY=sk_test_51Abc...
JWT_SECRET=dev-only-not-real-secret
When you run next dev, Next.js resolves variables in this order: process.env -> .env.development.local (missing) -> .env.local -> .env.development -> .env. The first file that defines a key wins.
The NEXT_PUBLIC_ Rule — The One Rule That Matters
Next.js draws a hard line between server env vars and client env vars based on a single prefix.
- No prefix: the variable exists only on the server. It's available in API routes, server components,
getServerSideProps, middleware, route handlers, and server actions. It is never included in the JavaScript bundle sent to browsers. NEXT_PUBLIC_prefix: Next.js inlines the variable's value directly into every JavaScript chunk at build time. It becomes a string literal in your client bundle. Anyone who views the page source or opens DevTools can read it.
// app/page.tsx — a server component
// Both variables work here because server components run on the server
export default function HomePage() {
// Server-only variable — safe
const dbUrl = process.env.DATABASE_URL;
// Public variable — also works (and will be inlined in client chunks too)
const apiUrl = process.env.NEXT_PUBLIC_API_URL;
return <h1>Connected to {apiUrl}</h1>;
}
// app/components/Checkout.tsx — a client component
'use client';
export function Checkout() {
// This WORKS — NEXT_PUBLIC_ vars are inlined at build time
const stripePk = process.env.NEXT_PUBLIC_STRIPE_PK;
// This is UNDEFINED in the browser — server-only vars are stripped
// If you ever write this line, you have a bug (or a future bug)
const dbUrl = process.env.DATABASE_URL;
return <div>Publishable key: {stripePk}</div>;
}
The key word is inlined. Next.js does not pass env vars to the browser at runtime like a config object. Instead, during next build, the compiler scans your code for process.env.NEXT_PUBLIC_* references and literally replaces each one with its string value. After the build, there is no process.env in the browser at all — there are only hardcoded strings.
What "Inlined at Build Time" Actually Looks Like
Before build:
// client component source
const url = process.env.NEXT_PUBLIC_API_URL;
fetch(`${url}/users`);
After next build, inside the minified client chunk:
// what actually ships to the browser
const url = "https://api.acme.com";
fetch(`${url}/users`);
This has two huge implications:
- You cannot change
NEXT_PUBLIC_values without rebuilding. If you changeNEXT_PUBLIC_API_URLon your host and restart the server, the browser still sees the old value — because it was baked into the JS file that's now in a CDN cache. - Anything with
NEXT_PUBLIC_is public forever. Once shipped, assume it's been scraped. Rotate any secret that was ever mistakenly prefixed.
Build-Time vs Runtime Environment Variables
A subtle but important distinction that trips up a lot of developers:
- Build-time env is read during
next build.NEXT_PUBLIC_vars are always build-time — they get inlined. Server vars referenced at the top level of a module (outside any function) are also effectively build-time, because module evaluation happens during the build for SSG pages. - Runtime env is read when the server process starts (or when a request comes in). Server vars read inside functions — API route handlers, server actions,
getServerSideProps— are runtime. You can change them by restarting the server, without rebuilding.
// lib/db.ts
// BUILD-TIME read (top-level). The value is frozen at build.
// Bad for Docker-based deploys where the image is built once, run many times.
const DB_URL = process.env.DATABASE_URL;
export function getDb() {
// RUNTIME read (inside a function). Re-evaluated each call.
// Good for Docker — one image, many environments.
return connect(process.env.DATABASE_URL);
}
For server-only secrets, prefer runtime reads (inside functions) so one Docker image can be promoted from staging to production without rebuilding. For NEXT_PUBLIC_ values, you have no choice — they're always build-time.
Validating Env Vars with zod and t3-env
Raw process.env.FOO returns string | undefined. If someone forgets to set DATABASE_URL in production, your app will crash at the first query with a cryptic error. The fix is to validate env vars at startup using a schema.
Simple Validation with zod
// lib/env.ts
import { z } from 'zod';
// Define the shape your app requires
const envSchema = z.object({
// Server-only
DATABASE_URL: z.string().url(),
STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
JWT_SECRET: z.string().min(32),
NODE_ENV: z.enum(['development', 'production', 'test']),
// Client-exposed (must start with NEXT_PUBLIC_)
NEXT_PUBLIC_API_URL: z.string().url(),
NEXT_PUBLIC_STRIPE_PK: z.string().startsWith('pk_'),
});
// Parse once at module load. Throws a readable error if anything is missing.
export const env = envSchema.parse(process.env);
Now import env anywhere in your server code instead of process.env:
// app/api/charge/route.ts
import { env } from '@/lib/env';
import Stripe from 'stripe';
// env.STRIPE_SECRET_KEY is guaranteed to exist and start with "sk_"
const stripe = new Stripe(env.STRIPE_SECRET_KEY);
The t3-env Approach (Recommended)
@t3-oss/env-nextjs goes one step further: it separates server and client schemas and throws a build-time error if you ever try to access a server var from a client file.
// lib/env.ts
import { createEnv } from '@t3-oss/env-nextjs';
import { z } from 'zod';
export const env = createEnv({
// Server-only — available only in server code
server: {
DATABASE_URL: z.string().url(),
STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
JWT_SECRET: z.string().min(32),
},
// Client-exposed — must start with NEXT_PUBLIC_
client: {
NEXT_PUBLIC_API_URL: z.string().url(),
NEXT_PUBLIC_STRIPE_PK: z.string().startsWith('pk_'),
},
// Manually pass env so t3-env can see them
runtimeEnv: {
DATABASE_URL: process.env.DATABASE_URL,
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
JWT_SECRET: process.env.JWT_SECRET,
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
NEXT_PUBLIC_STRIPE_PK: process.env.NEXT_PUBLIC_STRIPE_PK,
},
});
If a client component imports env.STRIPE_SECRET_KEY, the build fails with a clear error. This is the best defense against accidental secret leaks — the tool refuses to ship the bug.
How Tree-Shaking Protects You (Sometimes)
Next.js is smart enough to eliminate "server-only" code paths from client bundles via tree-shaking and dead-code elimination — but only when conditions are obvious at build time. Consider:
// lib/config.ts
export const isServer = typeof window === 'undefined';
export function getApiKey() {
if (isServer) {
return process.env.SECRET_API_KEY; // server branch
}
return process.env.NEXT_PUBLIC_API_KEY; // client branch
}
When Next.js bundles this for the browser, typeof window === 'undefined' is a constant the compiler knows is false in the browser, so the if branch becomes dead code and process.env.SECRET_API_KEY is eliminated. The secret never reaches the bundle.
But this is fragile. Rename isServer, store it in an object, or pass it through a function and the compiler gives up and keeps both branches — leaking the secret. Do not rely on tree-shaking for security. Use server-only:
// lib/secrets.ts
import 'server-only'; // Throws at build time if imported by client code
export const STRIPE_SECRET = process.env.STRIPE_SECRET_KEY;
The server-only package is a zero-runtime marker — importing it from a client component causes the build to fail immediately. It's the safest way to draw the line.
Vercel Environment Variables
On Vercel, env vars live in the project dashboard (Settings -> Environment Variables) and can be scoped to three environments:
+---------------------------------------------------------------+
| VERCEL ENV SCOPES |
+---------------------------------------------------------------+
| |
| Production -> main branch deploys |
| Preview -> PR branches and non-prod deploys |
| Development -> vercel env pull for local development |
| |
| Sensitive flag -> value is encrypted at rest and not shown |
| in the UI after saving |
| |
+---------------------------------------------------------------+
Running vercel env pull .env.local downloads the Development-scoped variables to your local file, keeping your laptop and Vercel in sync. Remember:
- Changing
NEXT_PUBLIC_vars requires a redeploy (they're baked into the build). - Changing server-only vars takes effect on the next cold start (usually within seconds).
- Preview deployments get their own scoped values, so you can safely test with staging databases without touching production.
Common Mistakes
1. Prefixing a real secret with NEXT_PUBLIC_ "to make the error go away."
The single most common catastrophe. Adding NEXT_PUBLIC_ to STRIPE_SECRET_KEY does make the warning disappear — because the key is now inlined into every JavaScript chunk you ship. Within hours, bots will scrape it from your site and drain your account. Treat the NEXT_PUBLIC_ prefix as a declaration of intent: "I am promising that this value is safe for the entire world to read." If that promise is false, never add the prefix. Rotate any key that was ever prefixed.
2. Reading secrets at the top level of a client-imported module.
You can define a secret in .env.local without NEXT_PUBLIC_, read it with process.env.MY_SECRET at the top of a file, and then import that file from a client component. The server value will be undefined in the browser (good), but the import graph will still pull the file into the client bundle — along with any other code it exports. This is how secrets accidentally leak even without the NEXT_PUBLIC_ prefix. Fix it by importing 'server-only' in any file that touches secrets.
3. Committing .env.local or .env.production.local to git.
These files should be in .gitignore by default — verify it. Once a secret is in git history, rotating the key is the only fix (even after git rm). Scan your history with git log --all --oneline -- .env.local and use a tool like gitleaks in CI to block future commits of secret files.
4. Expecting NEXT_PUBLIC_ changes to take effect without a rebuild.
Because these variables are inlined at build time, changing the Vercel dashboard value doesn't update anything until you redeploy. A developer updates NEXT_PUBLIC_API_URL in production, sees no change, adds more env vars, still no change, then spends an hour debugging a non-bug. The fix is always: trigger a new deploy. Consider adding a CI check that prints the inlined value so "did this rebuild?" is never a question.
5. Using process.env.NODE_ENV to check for "production" in places it doesn't mean what you think.
NODE_ENV is set by Next.js automatically: development during next dev, production during next build and next start, test during Jest. It does not distinguish between staging, preview, and actual production — all three are production. If you need to branch on "am I in the real production environment," use a custom variable like NEXT_PUBLIC_VERCEL_ENV or APP_ENV=staging. Otherwise your staging site will run production-only code like sending real emails.
Interview Questions
1. "What is the difference between a regular env var and one prefixed with NEXT_PUBLIC_?"
A regular env var exists only on the server. It's available in server components, API routes, route handlers, middleware, server actions, and getServerSideProps, but it is stripped from the JavaScript bundle sent to browsers. A NEXT_PUBLIC_ prefixed env var is inlined at build time: during next build, the compiler finds every process.env.NEXT_PUBLIC_FOO reference and replaces it with the string value, so the variable becomes a literal inside the client chunks. This means anything with the prefix is readable by any visitor via DevTools or view-source — so it must only hold non-secret values like public API URLs, analytics IDs, or publishable keys. Anything sensitive (database URLs, secret API keys, JWT signing secrets) must never have the prefix.
2. "What's the load order of .env files in Next.js, and which one should contain secrets?"
Next.js loads variables in this order, highest precedence first: process.env already set by the OS or CI, then .env.$(NODE_ENV).local (like .env.development.local), then .env.local (skipped when NODE_ENV=test), then .env.$(NODE_ENV), then .env. The first file to define a variable wins. Secrets should only live in files ending in .local, because those are gitignored by default. Committed files (.env, .env.development, .env.production) should contain only safe defaults and non-sensitive public values that differ between environments. In production on a platform like Vercel, secrets should live in the platform's env var UI, not in any file at all.
3. "Why can't you change a NEXT_PUBLIC_ variable without rebuilding?"
Because Next.js inlines these variables as string literals directly into the JavaScript bundle during next build. After the build, the browser code no longer contains any process.env reference — just the hardcoded string. There is nothing to update at runtime. Even if you change the value in your host's env UI and restart the Node process, the pre-built JavaScript files in .next/static and on any CDN still contain the old value, so visitors keep seeing the old one. To change a NEXT_PUBLIC_ value you must trigger a full rebuild and redeploy. Server-only env vars, by contrast, are read at runtime (as long as you read them inside a function), so restarting the server is enough.
4. "How would you ensure a server-only secret never leaks into a client bundle?"
Three layers of defense. First, never use the NEXT_PUBLIC_ prefix on it — that alone prevents the compiler from inlining it. Second, import 'server-only' at the top of any module that touches the secret; this is a zero-runtime marker package that causes an immediate build error if the file is ever imported from a client component, catching bugs at build time instead of in production. Third, validate env vars at startup with @t3-oss/env-nextjs, which declares separate server and client schemas and throws if client code tries to access a server variable. Together these make it practically impossible to ship a secret to the browser by accident — the tooling refuses to build the broken app.
5. "What does NODE_ENV mean in Next.js, and why is it a bad idea to use it to detect 'production'?"
Next.js sets NODE_ENV automatically based on the command you run: development for next dev, production for next build and next start, and test for test runners. It's useful for toggling dev-only behavior like verbose logging or React's strict mode warnings. The problem is that NODE_ENV=production is true for every production build — including staging, preview branches, QA, and the real production site. If you use if (process.env.NODE_ENV === 'production') to gate real side effects like sending emails, charging cards, or writing to the production database, those side effects will fire in staging too. For environment detection, use a custom variable like APP_ENV=staging|production or Vercel's built-in VERCEL_ENV which distinguishes production, preview, and development.
Quick Reference — Environment Variables Cheat Sheet
+---------------------------------------------------------------+
| ENV VAR CHEAT SHEET |
+---------------------------------------------------------------+
| |
| FILE LOAD ORDER (first wins): |
| 1. process.env |
| 2. .env.$(NODE_ENV).local |
| 3. .env.local |
| 4. .env.$(NODE_ENV) |
| 5. .env |
| |
| PREFIX RULE: |
| NEXT_PUBLIC_* -> inlined into client bundle (public) |
| anything else -> server only (never shipped) |
| |
| GITIGNORE: |
| *.local files are gitignored by default |
| secrets live ONLY in .local files or platform UI |
| |
| VALIDATE AT STARTUP: |
| import { createEnv } from '@t3-oss/env-nextjs' |
| separate server{} and client{} schemas |
| |
| PROTECT SERVER SECRETS: |
| import 'server-only' // fails build if used on client |
| |
+---------------------------------------------------------------+
+---------------------------------------------------------------+
| KEY RULES |
+---------------------------------------------------------------+
| |
| 1. Never prefix a real secret with NEXT_PUBLIC_ |
| 2. NEXT_PUBLIC_ values change only on rebuild |
| 3. Read server vars inside functions for runtime flexibility |
| 4. Validate env with zod / t3-env at startup |
| 5. Use 'server-only' to protect modules with secrets |
| 6. NODE_ENV does not mean "real production" |
| 7. Rotate any secret ever committed or ever prefixed |
| |
+---------------------------------------------------------------+
| Aspect | Server Env Var | NEXT_PUBLIC_ Env Var |
|---|---|---|
| Visible to browser | No | Yes (inlined) |
| Available in server components | Yes | Yes |
| Available in client components | No (undefined) | Yes |
| Read at | Runtime (inside functions) | Build time |
| Changes require rebuild | No | Yes |
| Safe for secrets | Yes | Never |
| Typical use | DB URL, API secret, JWT secret | Public API URL, analytics ID, publishable key |
| Vercel scope | Production / Preview / Development | Production / Preview / Development |
Prev: Lesson 8.1 -- Build and Output Next: Lesson 8.3 -- Deploying to Vercel and Self-Hosting
This is Lesson 8.2 of the Next.js Interview Prep Course -- 8 chapters, 33 lessons.