Suggest an editImprove this articleRefine the answer for “Environment variables in Next.js”. Your changes go to moderation before they’re published.Approval requiredContentWhat you’re changing🇺🇸EN🇺🇦UAPreviewTitle (EN)Short answer (EN)**Environment variables in Next.js** split into server-only (no prefix) and public (`NEXT_PUBLIC_` prefix) by naming convention. Server variables never reach the browser; public ones are inlined into the client bundle at build time. ```bash DATABASE_URL=secret # server-only NEXT_PUBLIC_API_URL=https://... # browser-safe ``` **Key point:** `NEXT_PUBLIC_` variables are replaced at `next build`, not at runtime. Changing them requires a rebuild.Shown above the full answer for quick recall.Answer (EN)Image**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.For the reviewerNote to the moderator (optional)Visible only to the moderator. Helps review go faster.