Skip to main content
Environment Variables in Next.js: Build-Time Inlining, Real Leak Paths, and Failing the Build with Zod

Environment Variables in Next.js: Build-Time Inlining, Real Leak Paths, and Failing the Build with Zod

August 17, 2026
Full-Stack Engineering
6 min read

A NEXT_PUBLIC_ environment variable is not read at runtime — Next.js replaces every process.env.NEXT_PUBLIC_* expression with a string literal during next build. That single fact explains the value that refuses to update without a rebuild, the dynamic lookup that returns undefined in the browser, and why one build artifact cannot serve two environments. At Devya we hit each of these on real Next.js projects, and this is the checklist our team now runs before the first deploy.

Key takeaways

• NEXT_PUBLIC_ variables are inlined into the client bundle at build time. Changing the value in a dashboard does nothing until the next build.

• Inlining is static text replacement: a dynamic lookup like process.env[name], or destructuring from process.env, returns undefined in the browser.

• Server-only variables are stripped from client bundles, so reading process.env.SECRET in a Client Component returns undefined. Real leaks travel through props serialized into the RSC payload, or through renaming a variable to NEXT_PUBLIC_.

• The server-only package turns importing a secrets module from a Client Component into a clear build error instead of a silent risk.

• Validating variables with a Zod schema at module load, imported from next.config.ts, makes a missing variable fail next build instead of the first production request.

Why did changing a NEXT_PUBLIC_ variable do nothing?

Because the value the browser sees was frozen 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 text, not a lookup, so editing the value in a dashboard or a Docker flag only changes what the next build will see.

The replacement is textual, which produces failures that look like Next.js bugs and are not: a dynamic key returns undefined, and destructuring from process.env returns undefined too, because neither is a literal expression the bundler can match. Server code has none of these restrictions — inside Server Components and route handlers, process.env stays a live Node.js object read at request time.

How does a server secret actually reach the browser?

Not through process.env — unprefixed variables are stripped from client bundles. The leaks we 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, so the value is embedded in the RSC payload inside the HTML response and is visible in the page source. The second is the "fix" reflex: a developer sees undefined in the browser, renames the variable to NEXT_PUBLIC_, the error disappears, and the secret becomes a string literal in a public JavaScript file.

The cheap defence is the server-only package: an empty module that fails the build if it ends up in the client import graph. We put it at the top of any file that touches secrets. If a Client Component ever imports that file — directly or through a chain — the build fails with a readable error instead of shipping the secret.

How do we fail the build when a variable is missing?

We parse the environment through a Zod schema in a module that executes 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 read it first.

Two details matter. We list the keys explicitly instead of passing process.env wholesale, because client variables must appear as literal expressions for the bundler to inline them. And we import the module from next.config.ts so the schema runs at build time even if no route touches it. The @t3-oss/env-nextjs package wraps the same idea with a built-in server/client split.

Which .env file wins?

Next.js loads .env files itself, with no need for the dotenv package, and with a fixed precedence: a variable already set in the shell beats every file, and more specific files beat general ones.

• .env — loaded in every environment; committed as shared defaults.

• .env.local — loaded everywhere except NODE_ENV=test; never committed — machine-local secrets.

• .env.development and .env.production — loaded when NODE_ENV matches; committed as per-environment defaults.

• .env.development.local and .env.production.local — highest precedence among files; never committed.

NODE_ENV itself is not free-form: Next.js recognises only development, production, and test. next dev forces the first; next build and next start force the second. A staging environment is NODE_ENV=production plus a custom variable such as APP_ENV=staging.

Can one build 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 the problem stays invisible because every environment gets its own build; a standalone Docker deployment that promotes one image across environments exposes it immediately.

Our exits, in order: keep configuration server-side and let client code call same-origin paths through a rewrite; read the value in a Server Component at request time and pass it down as a prop; or accept one build per environment. Editing the variable and redeploying the same artifact does not work, and it fails silently — the old value keeps being served with no error anywhere.

FAQ

Q: Do server-only variables reach the browser?

A: No. Next.js strips variables without the NEXT_PUBLIC_ prefix from client bundles, so they read as undefined. Exposure happens only when a secret is passed as a prop into a Client Component or renamed with the public prefix.

Q: Do we 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 creates a second, conflicting load order.

Q: Can NODE_ENV be set to staging?

A: No. Next.js recognises only development, production, and test. Model staging as NODE_ENV=production plus your own variable such as APP_ENV=staging.

Q: Why does process.env[name] return undefined in the browser?

A: Because inlining is a static build-time replacement of literal expressions only, and no process.env object exists in the browser to index with a dynamic key.

Q: How do we 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 — the schema gives both runtime guarantees and autocomplete.