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_URLreturnsundefinedin 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.localis git-ignored by default and overrides all other.envfiles.- Load order (highest to lowest):
.env.local>.env.[mode]>.env
Quick example
# .env.local
DATABASE_URL=postgresql://localhost:5432/mydb # server-only
NEXT_PUBLIC_API_URL=https://api.example.com # client-exposed// 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:
// 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]; // undefinedwebpack'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:
// 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
'use client';
const secret = process.env.DATABASE_URL; // undefined in browserFix: 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_KEYserver-only in Server Components;NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEYfor the client SDK - Clerk / NextAuth:
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEYfor browser SDK initialization - Supabase: service key server-only in Server Actions;
NEXT_PUBLIC_SUPABASE_URLfor the client SDK - Google Analytics:
NEXT_PUBLIC_GA_IDin 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
// 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
// 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
// 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 readyA concise answer to help you respond confidently on this topic during an interview.