Next.js splits environment variables into two worlds: anything prefixed NEXT_PUBLIC_ is inlined into the browser bundle at build time, and everything else stays server-only and never ships to the client. That one rule, plus the fact that public values are frozen at next build rather than read at runtime, is the root cause of most "undefined in production" bugs. Next.js 16 (October 2025) didn't change the prefix rule, but it made Turbopack the default bundler and removed publicRuntimeConfig/serverRuntimeConfig entirely — env vars are now the only built-in config channel.
TL;DR
- Only
NEXT_PUBLIC_-prefixed vars reach the browser; everything else is server-only and stripped from the client bundle. - Public vars are inlined at build time, not read at runtime — changing one means a rebuild, not just a restart.
- Variable lookup stops at the first match:
process.env→.env.{NODE_ENV}.local→.env.local→.env. Earlier sources win. - Next.js 16 removed
publicRuntimeConfig/serverRuntimeConfigand renamedmiddleware.tstoproxy.ts. - Never destructure or use dynamic keys on
process.envfor public vars — static replacement only matches the literal full expression.
Which .env files does Next.js load?
Next.js loads environment files in a specific order using @next/env. Every file is optional. Values from files loaded later do not override values from earlier files — the first definition wins.
.env.{NODE_ENV}.local— e.g..env.development.local(highest priority, git-ignored).env.local— local overrides, always loaded except intestenvironment.env.{NODE_ENV}— e.g..env.production.env— base defaults (lowest priority)
The NODE_ENV value is set automatically: development for next dev, production for next build and next start, and test when set explicitly for test runners.
# .env — shared defaults
DATABASE_URL=postgres://localhost:5432/myapp
NEXT_PUBLIC_APP_NAME=MyApp
# .env.local — developer-specific overrides (git-ignored)
DATABASE_URL=postgres://localhost:5432/myapp_dev
SECRET_KEY=dev-only-secret
# .env.production — production defaults
NEXT_PUBLIC_API_URL=https://api.example.comsrc/ directory, your .env* files must still sit at the project root, not inside src/. Next.js only reads them from the parent folder, so a src/.env.local is silently ignored — a common cause of "the variable is right there but it's undefined".Inside a file, Next.js expands $VAR references to other variables, so API_URL=https://$HOST/v1 resolves at load time. If a literal $ belongs in the value (a password, say), escape it as \$ or it gets eaten by the expansion.
What does the NEXT_PUBLIC_ prefix do?
This is the single most important rule in Next.js environment variable handling. Only variables prefixed with NEXT_PUBLIC_ are exposed to the browser. Everything else is server-only and never included in the client JavaScript bundle.
# Server-only — NEVER sent to the browser
DATABASE_URL=postgres://user:pass@db:5432/app
STRIPE_SECRET_KEY=sk_live_abc123
# Client-safe — inlined into the browser bundle at build time
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_xyz789
NEXT_PUBLIC_API_URL=https://api.example.comAt build time, Next.js uses DefinePlugin (the same webpack-versus-Vite define step Vite handles with import.meta.env) — or, since Turbopack became the default bundler in Next.js 16, the Turbopack equivalent — to statically replace every occurrence of process.env.NEXT_PUBLIC_* with the literal string value. This means public env vars are baked into the output at build time: promoting one Docker image across staging and prod freezes every NEXT_PUBLIC_ value at the build environment's setting, so changing them requires a rebuild, not a redeploy.
NEXT_PUBLIC_ to a secret, that secret ships in your client bundle in plain text and stays there for every cached build until you rebuild — anyone can read it in DevTools. This is one of the most common ways secrets leak from a frontend; see our env var security guide for the broader threat model, or audit your files with the env validator.How do you access env vars in Server Components?
Server Components (the default in the App Router) run exclusively on the server. You have full access to all environment variables via process.env:
// app/dashboard/page.tsx — Server Component (default)
export default async function DashboardPage() {
// Server-only vars work fine here
const dbUrl = process.env.DATABASE_URL;
const data = await fetch(process.env.INTERNAL_API_URL + '/stats');
// NEXT_PUBLIC_ vars also work on the server
const appName = process.env.NEXT_PUBLIC_APP_NAME;
return <h1>{appName} Dashboard</h1>;
}Important: Next.js does not allow destructuring process.env because the replacement is a static string substitution. Always use the full process.env.VARIABLE_NAME expression.
// This will NOT work — destructuring breaks static replacement
const { DATABASE_URL } = process.env; // undefined
// This works
const dbUrl = process.env.DATABASE_URL; // "postgres://..."How do you access env vars in Client Components?
Client Components (marked with 'use client') only have access to NEXT_PUBLIC_ variables. Server-only variables return undefined:
'use client';
export function Analytics() {
// Works — public variable is inlined at build time
const trackingId = process.env.NEXT_PUBLIC_GA_ID;
// undefined — server-only vars are stripped from client bundles
const secret = process.env.API_SECRET; // undefined
return <script data-id={trackingId} />;
}Can you read env vars at runtime instead of build time?
Yes — but only server-side, and only for values you read during dynamic rendering. Server-only process.env reads inside a request are evaluated at runtime, so the same Docker image promoted from staging to production picks up each environment's values. The catch: a Server Component is statically rendered by default, which can bake the value in. Opt into dynamic rendering with connection() (or any request API like cookies()) so the read happens per-request:
// app/page.tsx — read a server var at request time
import { connection } from 'next/server';
export default async function Page() {
await connection(); // opt into dynamic rendering
const value = process.env.MY_RUNTIME_VALUE; // evaluated per request
return <p>{value}</p>;
}There is no runtime equivalent for NEXT_PUBLIC_ values: once inlined, they are frozen. If the browser needs a value that changes per environment, expose it through a server endpoint and fetch it at runtime. Next.js 16 also removed the old publicRuntimeConfig and serverRuntimeConfig options from next.config.js — env vars and this connection() pattern are the supported replacements. For platform-specific runtime config on Vercel, see the Vercel environment variables guide.
How do you use env vars in API Routes and Middleware?
Route Handlers and Middleware run on the server, so all variables are available:
// app/api/webhook/route.ts
export async function POST(request: Request) {
const secret = process.env.WEBHOOK_SECRET;
const signature = request.headers.get('x-signature');
if (!verify(signature, secret)) {
return Response.json({ error: 'Invalid' }, { status: 401 });
}
return Response.json({ ok: true });
}
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const allowedOrigin = process.env.ALLOWED_ORIGIN;
// Full access to server-only env vars
return NextResponse.next();
}Next.js 16 note: middleware.ts is deprecated in favor of proxy.ts (export a proxy function instead of middleware). The env var access is identical — only the filename and export name changed. middleware.ts still works for Edge-runtime cases but will be removed in a future release.
What is the full load order and precedence?
When the same variable is defined in multiple files, the first value found wins. Next.js checks in this order:
process.env(actual shell environment — always wins).env.{NODE_ENV}.local.env.local(skipped whenNODE_ENV=test).env.{NODE_ENV}.env
This means a DATABASE_URL set in your shell or CI environment will always override anything in your .env files. The mechanism is the same process.env that plain Node.js uses; Next.js just layers the .env* file resolution on top via @next/env. For a deeper look at .env file syntax and conventions, see our .env guide.
Why is my env var undefined in production?
This is the most common issue developers hit with Next.js environment variables. The causes differ based on whether the variable is public or server-only. For a symptom-by-symptom walkthrough of every undefined failure mode — client vs server, build-time freezing, dynamic-key reads, and dev-server restarts — see the Next.js env variables undefined troubleshooting guide.
NEXT_PUBLIC_ var is undefined on the client
- The variable was not set at build time. Public vars are inlined during
next build. Setting them at runtime has no effect. - You are using destructuring:
const { NEXT_PUBLIC_X } = process.envdoes not work. - You are computing the key dynamically:
process.env[key]does not work for public vars because the replacement is static.
Server-only var is undefined
- The variable is not set in the deployment environment. Check your hosting provider's env var configuration (Vercel, Cloudflare, AWS, etc.).
.env.localis not deployed — it is git-ignored by default and only exists on your local machine.- You added it to
.env.developmentbut production uses.env.production.
How do you add type safety for env vars?
Extend the ProcessEnv interface so TypeScript catches typos:
// env.d.ts
declare namespace NodeJS {
interface ProcessEnv {
DATABASE_URL: string;
STRIPE_SECRET_KEY: string;
NEXT_PUBLIC_APP_URL: string;
NEXT_PUBLIC_GA_ID: string;
}
}For runtime validation, parse your env vars at startup with a schema library:
// lib/env.ts
import { z } from 'zod';
const serverSchema = z.object({
DATABASE_URL: z.string().url(),
STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
});
const clientSchema = z.object({
NEXT_PUBLIC_APP_URL: z.string().url(),
});
// Validate on import — fails fast if misconfigured
export const serverEnv = serverSchema.parse(process.env);
export const clientEnv = clientSchema.parse({
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
});Best Practices
- Never prefix secrets with NEXT_PUBLIC_. Double-check every variable before adding the prefix. API keys, database credentials, and signing secrets must stay server-only.
- Commit a .env.example file. Document every required variable with empty or placeholder values. This file serves as onboarding documentation and can be validated in CI.
- Validate at build time. Use a schema validator (Zod, Valibot, or similar) to fail the build immediately when required variables are missing.
- Set production vars in your hosting platform. Do not rely on
.env.productionfor secrets — use Vercel Environment Variables, Cloudflare secrets, or your CI provider's secret store. - Keep .env.local out of version control. Next.js adds it to
.gitignoreby default withcreate-next-app. Verify this is the case in your repo. - Use the full process.env.VAR syntax. Never destructure, never use dynamic keys. The static replacement requires the exact literal expression.
process.env (const { X } = process.env returns undefined for public vars). Don't use dynamic keys (process.env[key] bypasses the static replacement). NEXT_PUBLIC_ values are baked into the bundle at build time — changing them requires a rebuild. .env.local is skipped when NODE_ENV=test; use .env.test or .env.test.local instead.References
- Next.js Docs: Environment Variables — official guide covering load order, the
NEXT_PUBLIC_prefix, the/srcfolder rule,$variable expansion, and runtime vs build-time semantics. - Next.js Docs:
connection()— the function that opts a Server Component into dynamic rendering so server env vars are read at request time instead of being baked in at build. - Next.js 16 release notes — Turbopack as the default bundler, the
middleware.ts→proxy.tsrename, and the removal ofpublicRuntimeConfig/serverRuntimeConfig. - Next.js Docs: Non-standard NODE_ENV — why Next.js warns when
NODE_ENVis set to anything other thandevelopment,production, ortest. @next/envsource — the package that implements.envfile loading inside Next.js.- Vercel Docs: Environment Variables — how to configure production, preview, and development env vars on Vercel.
- Zod — schema validation library used in the type-safety example above.
Validate your .env files for syntax errors and common mistakes with the env validator tool, or read the comprehensive .env guide for syntax and cross-language usage.