env.dev

Next.js Environment Variables: Complete Guide

How Next.js handles environment variables: .env file load order, NEXT_PUBLIC_ prefix, server vs client access, and common production errors.

By env.dev Updated

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/serverRuntimeConfig and renamed middleware.ts to proxy.ts.
  • Never destructure or use dynamic keys on process.env for 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.

  1. .env.{NODE_ENV}.local — e.g. .env.development.local (highest priority, git-ignored)
  2. .env.local — local overrides, always loaded except in test environment
  3. .env.{NODE_ENV} — e.g. .env.production
  4. .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.

ini
# .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.com
The /src gotcha: if your app lives in a src/ 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.

ini
# 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.com

At 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.

Security implication: If you accidentally add 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:

tsx
// 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.

typescript
// 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:

tsx
'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:

tsx
// 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:

typescript
// 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:

  1. process.env (actual shell environment — always wins)
  2. .env.{NODE_ENV}.local
  3. .env.local (skipped when NODE_ENV=test)
  4. .env.{NODE_ENV}
  5. .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.env does 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.local is not deployed — it is git-ignored by default and only exists on your local machine.
  • You added it to .env.development but production uses .env.production.

How do you add type safety for env vars?

Extend the ProcessEnv interface so TypeScript catches typos:

typescript
// 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:

typescript
// 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.production for 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 .gitignore by default with create-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.
Common pitfalls: Don't destructure 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 /src folder 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.ts rename, and the removal of publicRuntimeConfig/serverRuntimeConfig.
  • Next.js Docs: Non-standard NODE_ENV — why Next.js warns when NODE_ENV is set to anything other than development, production, or test.
  • @next/env source — the package that implements .env file 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.

Was this helpful?

Frequently Asked Questions

Why are my Next.js environment variables undefined in production?

For client-side values, you forgot the NEXT_PUBLIC_ prefix or set the variable after next build — public vars are inlined at build time, not read at runtime. For server-side values, the variable is usually missing from your hosting platform: .env.local is git-ignored and never deployed, so set secrets in Vercel/Cloudflare/your CI store, not in a committed .env file.

What does NEXT_PUBLIC_ do in Next.js?

The NEXT_PUBLIC_ prefix tells Next.js to inline the variable into the client-side JavaScript bundle at build time. Without this prefix, environment variables are only available in server-side code (API routes, getServerSideProps, Server Components).

What is the .env file load order in Next.js?

Next.js looks variables up in this order and stops at the first match: process.env (shell/CI), .env.{NODE_ENV}.local, .env.local, .env.{NODE_ENV}, then .env. The first definition wins — earlier files take precedence over later ones, not the reverse. .env.local is skipped when NODE_ENV=test.

How do I use different API URLs per environment?

Define NEXT_PUBLIC_API_URL in each environment file — e.g. http://localhost:4000 in .env.development and https://api.example.com in .env.production. Next.js loads the correct file based on NODE_ENV.

Stay up to date

Get notified about new guides, tools, and cheatsheets.