The two questions
Every environment variable in a Next.js app has two properties that determine how it behaves: where it is read (server or browser) and when it is read (at build or at request). Most confusion, the API key that ended up in the client bundle, the config change that needed a redeploy, the variable that is undefined in middleware, comes from not knowing which of the four combinations a given variable is in.
Where: server versus browser
Server-only is the default. process.env.DATABASE_URL in a Server Component, a Route Handler, a Server Action or next.config is read on the server and never sent to the browser. This is where secrets live.
Browser-exposed requires the NEXT_PUBLIC_ prefix. process.env.NEXT_PUBLIC_ANALYTICS_ID is inlined into the client JavaScript at build time and is visible to anyone who opens the source. Nothing secret goes here, ever. The prefix is not a convenience; it is a declaration that the value is public.
A variable without the prefix, referenced in a Client Component, is undefined in the browser. This surprises people who expected a runtime error. Check for it.
When: build versus request
Build time. NEXT_PUBLIC_ variables are replaced with their literal values when the bundle is compiled. So are any process.env references inside code that runs during static generation. Changing the value on the hosting platform after the build does nothing until you rebuild.
Request time. Server code that runs per request, dynamic Server Components, Route Handlers, Server Actions, middleware, reads process.env from the running process. Change the value on the platform, restart or redeploy without rebuilding, and the new value is live.
The trap is code that you think runs at request time but is being prerendered. With Cache Components, anything inside a use cache scope or a fully static page runs at build. A process.env.FEATURE_FLAG read inside a cached component is frozen at build, and changing the flag in the dashboard does not flip it.
Next.js provides unstable_noStore and, in the cache model, the rule that dynamic reads must sit outside cached scopes. If a variable must be read fresh on every request, read it in a dynamic context: a component outside use cache, behind Suspense, or in a Route Handler.
The Edge runtime
Middleware and any route with export const runtime = 'edge' run in the Edge runtime, not Node. Environment variables work, with differences:
- They are read at request time, from the platform's edge environment, so they must be configured for the edge as well as for serverless functions. On Vercel this is automatic; on other hosts check that edge and node functions receive the same set.
- The runtime has no filesystem, so
.envfiles are not read at request time; only build-time injection and the platform's environment apply. - Some Node-specific patterns (
dotenv,process.cwd()) are unavailable.
process.env in middleware being empty is nearly always the platform not providing variables to the edge, or the variable being expected from a .env file that only local development reads.
The .env files
.env (all environments, committed with non-secret defaults), .env.local (local overrides, never committed), .env.development and .env.production (per mode), .env.test. Loaded at build and in next dev. Not loaded by the production server on most platforms; production reads the platform's environment.
Commit .env.example with every variable name and a comment, and nothing secret. It is the documentation of what the app needs.
Validating at startup
An app that starts with a missing variable and fails on the first request that needs it is worse than one that refuses to start. Validate once:
// lib/env.ts
import { z } from 'zod'
const schema = z.object({
DATABASE_URL: z.string().url(),
SMTP_HOST: z.string().min(1),
SMTP_PASS: z.string().min(1),
NEXT_PUBLIC_SITE_URL: z.string().url(),
})
export const env = schema.parse({
DATABASE_URL: process.env.DATABASE_URL,
SMTP_HOST: process.env.SMTP_HOST,
SMTP_PASS: process.env.SMTP_PASS,
NEXT_PUBLIC_SITE_URL: process.env.NEXT_PUBLIC_SITE_URL,
})Import env instead of touching process.env directly. A missing variable fails the build or the first server start with a clear message. List the keys explicitly rather than spreading process.env; the explicit reference is what lets the bundler inline NEXT_PUBLIC_ values.
Split the schema into server and client halves if the client needs typed access to its public variables, and never import the server half from a Client Component; the server-only package turns that mistake into a build error.
Patterns that prevent the common failures
Secret in the client bundle. Only ever reached via the NEXT_PUBLIC_ prefix, so the rule is: nothing with that prefix is secret. Review any variable with the prefix as if it were printed on the home page.
Config change did not take effect. The value is baked at build. Either rebuild, or move the read into a dynamic context so it is read per request.
Different value in preview and production. Platforms scope variables per environment. A preview deployment reading production's database because the variable was only set once is a real risk; set every environment explicitly.
Works locally, undefined in production. .env.local is not on the server. Set the variable on the platform.
Undefined in middleware. Edge environment not configured, or .env file expected at runtime.
Leaking through next.config. Variables referenced in next.config's env key are exposed to the client. Do not put secrets there.
Where this fits
Environment handling is set up on day one of every Next.js project we build: the schema file, the example file, the per-environment values on the platform, and a check that no NEXT_PUBLIC_ variable holds anything a stranger should not read. It is dull, and it is the difference between a config change being a dashboard edit and being an incident.