Skip to main content

Environment variables in Next.js

Environment variables in Next.js are configuration values loaded from .env files, split into server-only and client-exposed based on one naming rule: add NEXT_PUBLIC_ to a variable name to expose it to the browser.

Theory

TL;DR

  • Variables without a prefix are server-only. DATABASE_URL returns undefined in browser code.
  • Add NEXT_PUBLIC_ prefix to expose a variable to the client bundle.
  • Public vars are inlined at next build. Changing them requires a rebuild.
  • .env.local is git-ignored by default and overrides all other .env files.
  • Load order (highest to lowest): .env.local > .env.[mode] > .env

Quick example

bash
# .env.local DATABASE_URL=postgresql://localhost:5432/mydb # server-only NEXT_PUBLIC_API_URL=https://api.example.com # client-exposed
tsx
// Server Component - reads everything async function Dashboard() { const db = process.env.DATABASE_URL; // "postgresql://..." const api = process.env.NEXT_PUBLIC_API_URL; // "https://api.example.com" } // Client Component - NEXT_PUBLIC_ only 'use client'; function Widget() { const api = process.env.NEXT_PUBLIC_API_URL; // "https://api.example.com" const db = process.env.DATABASE_URL; // undefined }

Server sees both. Browser only sees NEXT_PUBLIC_ vars.

The server/client split

Next.js enforces this split at the bundler level. Variables without NEXT_PUBLIC_ load on the server via Node.js dotenv and never enter the webpack bundle. Public variables go through webpack's DefinePlugin, which replaces process.env.NEXT_PUBLIC_API_URL with the literal string "https://api.example.com" inside the client JavaScript.

That replacement happens once, at next build. So if you update NEXT_PUBLIC_API_URL in .env.production and run next dev, the browser still gets the old build-time value.

File conventions

.env - all environments .env.local - local overrides (git-ignored) .env.development - development only .env.production - production only .env.test - test only

.env.local wins over everything. Use it for developer-specific values and secrets you never commit.

When to use which

  • DB URLs, API keys, JWT secrets: no prefix (server-only)
  • Analytics IDs, public API endpoints, Stripe publishable key: NEXT_PUBLIC_
  • Variables that change per deploy on Vercel: set in the dashboard, no prefix needed
  • Local testing values: .env.local

How webpack handles public vars

NEXT_PUBLIC_ variables get statically replaced in the bundle. Dynamic access does not work on the client:

tsx
// Works const url = process.env.NEXT_PUBLIC_API_URL; // Does NOT work in the browser const key = "NEXT_PUBLIC_API_URL"; const url = process.env[key]; // undefined

webpack's DefinePlugin can only replace literal process.env.NEXT_PUBLIC_* references. Runtime property access returns undefined.

TypeScript declarations

Add a declaration file to get autocomplete and catch typos at build time:

typescript
// env.d.ts declare namespace NodeJS { interface ProcessEnv { DATABASE_URL: string; JWT_SECRET: string; NEXT_PUBLIC_API_URL: string; NODE_ENV: "development" | "production" | "test"; } }

This gives process.env.DATABASE_URL the type string instead of string | undefined. Worth adding to any production project.

Common mistakes

Mistake: reading a server variable inside a use client component

tsx
'use client'; const secret = process.env.DATABASE_URL; // undefined in browser

Fix: read the variable in a Server Component and pass only the data you need as a prop. Never pass the raw secret down.

Mistake: committing .env.local to git

That file is git-ignored for a reason. Check .gitignore, use your deployment platform (Vercel dashboard, GitHub Actions secrets) for production values, and keep a .env.example with placeholder values for teammates.

Mistake: expecting a NEXT_PUBLIC_ change to take effect without a rebuild

Public vars are baked into the bundle at next build. Updating one in .env.production and running next dev still shows the old value. Rebuild to apply.

Mistake: dynamic process.env access for public vars

process.env[variableName] returns undefined in the browser for NEXT_PUBLIC_ variables. Use the literal form only.

Real-world usage

  • Stripe: STRIPE_SECRET_KEY server-only in Server Components; NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY for the client SDK
  • Clerk / NextAuth: NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY for browser SDK initialization
  • Supabase: service key server-only in Server Actions; NEXT_PUBLIC_SUPABASE_URL for the client SDK
  • Google Analytics: NEXT_PUBLIC_GA_ID in a client-side analytics component
  • PlanetScale / Turso: DB connection strings always server-only in API routes

I have seen NEXT_PUBLIC_ variables turn up in git history more than once when someone added them to .env instead of .env.local. The damage is usually minor (a publishable key, not a secret), but the habit of checking is worth building early.

Follow-up questions

Q: What is the load order for .env files?
A: .env.local has the highest priority, then .env.[mode] (like .env.development), then .env. The first match wins.

Q: Can you read process.env in next.config.js?
A: Yes. next.config.js runs at build time on the server, so any env var is readable there without the NEXT_PUBLIC_ prefix.

Q: Why does my NEXT_PUBLIC_ var return undefined after a rebuild?
A: Almost always a dynamic access pattern. process.env[key] does not work for public vars. Use process.env.NEXT_PUBLIC_YOUR_VAR as a direct literal reference.

Q: (Senior) In the Vercel Edge Runtime, why might process.env behave differently?
A: Edge Runtime does not run full Node.js, so dotenv does not load files from the filesystem. Variables need to be set in the Vercel dashboard and are injected at the platform level, not read from .env files at runtime.

Q: How do you validate env vars at startup?
A: Use a lib/env.ts file with zod or plain checks: if (!process.env.DB_URL) throw new Error("Missing DB_URL"). Failing at boot is much easier to debug than failing mid-request.

Examples

Basic: server vs client access

tsx
// app/page.tsx - Server Component export default async function Home() { const dbUrl = process.env.DATABASE_URL; // Has the full connection string here return <ClientWidget />; } // components/ClientWidget.tsx 'use client'; export function ClientWidget() { const apiUrl = process.env.NEXT_PUBLIC_API_URL; // "https://api.example.com" const dbUrl = process.env.DATABASE_URL; // undefined return <p>API: {apiUrl}</p>; }

The Server Component has both. The Client Component only gets the public one. That is the whole mechanism in one file pair.

Real-world: Stripe in a dashboard

tsx
// app/dashboard/page.tsx - Server Component import Stripe from 'stripe'; export default async function Dashboard() { const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); const prices = await stripe.prices.list(); return ( <CheckoutButton publishableKey={process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!} prices={prices.data} /> ); } // components/CheckoutButton.tsx 'use client'; export function CheckoutButton({ publishableKey, prices }) { // publishableKey arrives as a prop - the secret never left the server return <button>Pay</button>; }

Secret key stays on the server. Publishable key reaches the browser via NEXT_PUBLIC_. This is the standard pattern in the Vercel Commerce template.

ISR: runtime vs build-time

tsx
// app/post/[id]/page.tsx export const revalidate = 3600; // revalidate every hour export default async function Post({ params }: { params: { id: string } }) { // Reads from process.env at *runtime* on each revalidation const apiKey = process.env.API_KEY; const data = await fetch(`https://api.example.com/${params.id}`, { headers: { Authorization: `Bearer ${apiKey}` } }).then(r => r.json()); return <div>{data.title}</div>; }

Server vars without prefix load at runtime, so updating API_KEY in the Vercel dashboard takes effect on the next revalidation without a rebuild. A NEXT_PUBLIC_ var in the same position would still use the old build-time value. That distinction matters in ISR and edge deploys.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?