Headline: A
NEXT_PUBLIC_environment variable is not read at runtime — Next.js replaces everyprocess.env.NEXT_PUBLIC_*expression with a string literal at build time. That single fact explains the value that refuses to update without a rebuild, the dynamic lookup that returnsundefinedin the browser, and why one build artifact cannot serve two environments.
An environment variable in Next.js is one name with two different lives. On the server it lives in process.env, a real Node.js object read at request time. In the browser it does not exist at all: any variable prefixed with NEXT_PUBLIC_ is copied into the JavaScript bundle as a literal during next build, and everything else is stripped. I have watched the same three failures come out of that split on several projects this year — a stale API URL that survived a redeploy, a secret that nearly rode a prop into the HTML, and a missing variable that surfaced as a runtime crash instead of a failed build. These notes are the checklist I now run before the first deploy.
Key takeaways
-
NEXT_PUBLIC_variables are inlined into the client bundle at build time. Changing the value in your host's dashboard does nothing until the next build. - Inlining is static text replacement.
process.env[name]with a dynamic key andconst { NEXT_PUBLIC_X } = process.envboth returnundefinedin the browser. - Server-only variables are stripped from client bundles, so
process.env.SECRETin a Client Component isundefined, not a leak. Secrets leak through props serialized into the RSC payload and through renaming a variable toNEXT_PUBLIC_. - The
server-onlypackage turns "a Client Component imported my secrets module" into a build error instead of a silent risk. - Validate environment variables with a Zod schema at module load and import that module in
next.config.ts, so a missing variable failsnext buildinstead of the first production request.
Why did changing my NEXT_PUBLIC_ variable do nothing?
Because the value the browser sees was compiled into the JavaScript at the last build. During next build, the bundler performs a find-and-replace: every static process.env.NEXT_PUBLIC_API_URL expression becomes the string the variable held on the build machine at that moment. The deployed bundle contains the literal URL, not a lookup. Editing the variable in a dashboard, a Docker -e flag, or .env.production changes what the next build will see — the running one is frozen.
The replacement is textual, which produces a failure mode that looks like a bug in Next.js and is not:
// Replaced at build time with a string literal — works in the browser
const url = process.env.NEXT_PUBLIC_API_URL;
// undefined in the browser: inlining is static text replacement,
// and no process.env object exists at runtime to index into
const name = 'NEXT_PUBLIC_API_URL';
const url2 = process.env[name];
// Also undefined after bundling: destructuring is not a static reference
const { NEXT_PUBLIC_API_URL } = process.env;
Server code has none of these restrictions. Inside a Server Component, a route handler, or a Server Action, process.env is the live Node.js object, read at request time, dynamic keys and all.
How does a server secret actually reach the browser?
Not through process.env — Next.js strips unprefixed variables from client bundles, so process.env.STRIPE_SECRET_KEY in a Client Component evaluates to undefined. The leaks I have actually seen take two other roads. The first is serialization: a Server Component reads a secret and passes it as a prop to a Client Component, and the value is embedded in the RSC payload inside the HTML response, visible in View Source. The second is the "fix" reflex: a developer sees undefined in the browser, renames API_SECRET to NEXT_PUBLIC_API_SECRET, the error disappears, and the secret is now a string literal in a public JavaScript file.
The cheap defence is the server-only package — an empty module whose import fails the build if it ends up in the client graph:
// lib/secrets.ts
import 'server-only';
export const stripeSecret = process.env.STRIPE_SECRET_KEY!;
export const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
Any module that touches secrets gets that import at the top. If a Client Component ever imports it, directly or through a chain, the build fails with a readable error instead of shipping. React's experimental taint API (experimental_taintUniqueValue) covers the prop-serialization road as well, but server-only is stable today and catches the common case.
How do I fail the build when a variable is missing?
Parse the environment through a Zod schema in a module that runs during the build, and import every variable through it. A missing or malformed variable then stops next build with a named error instead of surfacing as undefined in whatever code happened to read it first.
// src/env.ts
import { z } from 'zod';
const schema = z.object({
DATABASE_URL: z.string().url(),
STRIPE_SECRET_KEY: z.string().min(1),
NEXT_PUBLIC_API_URL: z.string().url(),
});
export const env = schema.parse({
DATABASE_URL: process.env.DATABASE_URL,
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
// Client vars must be referenced literally so the bundler can inline them
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
});
Two details earn their place. The keys are listed explicitly instead of passing process.env wholesale, because client-referenced variables must appear as literal member expressions for the bundler to inline them. And importing this module from next.config.ts (import './src/env';) forces the schema to execute at build time even if no route touches it. The @t3-oss/env-nextjs package wraps the same idea with a server/client split.
Which .env file wins, and which ones do I commit?
Next.js loads .env files itself — no dotenv package needed — with a fixed precedence: an already-set shell variable beats every file, and more specific files beat general ones.
| File | Loaded when | Commit it? |
|---|---|---|
.env |
Every environment | Yes — shared defaults |
.env.local |
Every environment except NODE_ENV=test
|
No — machine secrets |
.env.development / .env.production
|
When NODE_ENV matches |
Yes — per-env defaults |
.env.development.local / .env.production.local
|
When NODE_ENV matches; beats other files |
No |
NODE_ENV itself is not free-form: Next.js recognises exactly development, production, and test. next dev forces the first; next build and next start force the second. A staging environment is therefore NODE_ENV=production plus your own variable such as APP_ENV=staging. One more sharp edge: .env.local is deliberately ignored when NODE_ENV=test, so test runs stay reproducible across machines.
Can one build artifact serve both staging and production?
For server-side variables, yes — they are read at request time, so the same Docker image can boot with different DATABASE_URL values. For NEXT_PUBLIC_ variables, no — their values are already inside the JavaScript. On Vercel this stays invisible because every environment gets its own build with its own variables. A output: 'standalone' Docker deployment that promotes the same image across environments exposes it immediately.
Three honest ways out, in the order I try them: keep configuration server-side and let client code call same-origin paths (a rewrite that proxies /api removes most reasons a browser needs an absolute URL); read the value in a Server Component at request time and pass it down as a prop; or accept one build per environment. What does not work is editing the variable and redeploying the same artifact — and it fails silently, because the old value keeps being served with no error anywhere.
FAQ
Q: Are my server-only variables exposed to the browser?
A: No. Next.js strips variables without the NEXT_PUBLIC_ prefix from client bundles, so reading one in a Client Component returns undefined. Exposure happens when a secret is passed as a prop into a Client Component or renamed to a NEXT_PUBLIC_ variable.
Q: Do I need the dotenv package in a Next.js project?
A: No. Next.js loads .env, .env.local, and the NODE_ENV-specific variants itself, in a documented order. Adding dotenv on top usually just creates a second, conflicting load order.
Q: Can I set NODE_ENV to staging?
A: No. Next.js recognises only development, production, and test, and the CLI commands set it for you. Model staging as NODE_ENV=production plus your own variable, for example APP_ENV=staging.
Q: Why is process.env[name] undefined in the browser?
A: Because inlining is a static build-time replacement of literal process.env.NEXT_PUBLIC_* expressions. There is no process.env object in the browser to index with a dynamic key, so only the literal form works.
Q: How do I get type-safe environment variables in TypeScript?
A: Export a Zod-validated env object from one module and import it everywhere instead of touching process.env directly. Augmenting the ProcessEnv type gives autocomplete but no runtime guarantee; the schema gives both.
Originally published on devya.dev. Also on eng-ahmed.com. Built by Devya Solutions.
Top comments (0)