TL;DR
In Next.js, any environment variable whose name starts with NEXT_PUBLIC_ is inlined into the client bundle at build time. Everything else stays server-only. Get this wrong and you either (a) leak secrets to the browser or (b) read undefined in your React component and wonder why.
Server-only by default
Next.js treats process.env.ANYTHING as server-only unless the key starts with NEXT_PUBLIC_. This is a deliberate security default — it means DATABASE_URL, STRIPE_SECRET_KEY, and OPENAI_API_KEY can never accidentally ship to the browser just because you imported them in a shared module.
In practice:
# .env.local
DATABASE_URL=postgres://... # server only
STRIPE_SECRET_KEY=sk_test_... # server only
NEXT_PUBLIC_SITE_URL=https://myapp.com # browser + server
NEXT_PUBLIC_STRIPE_PK=pk_test_... # browser + serverHow the build pipeline handles it
When you run next build, Next.js walks every client module and does a textual replacement — every reference to process.env.NEXT_PUBLIC_FOO becomes the literal string of that value, baked into the JavaScript bundle. Two consequences:
- Changing a
NEXT_PUBLIC_value requires a rebuild. The production bundle is frozen. Editing the env in Vercel's dashboard won't update the running app until you redeploy. - Non-public variables aren't bundled. A client component referencing
process.env.DATABASE_URLwill just getundefinedat runtime. Next.js doesn't warn — it silently resolves toundefined.
Dynamic lookups are not inlined
Inlining is a literal text replacement, so it only works when the full variable name appears in your code. These patterns read undefined in the browser even though the variable is set:
const name = "NEXT_PUBLIC_ANALYTICS_ID";
process.env[name]; // ❌ not inlined
const env = process.env;
env.NEXT_PUBLIC_ANALYTICS_ID; // ❌ not inlined
process.env.NEXT_PUBLIC_ANALYTICS_ID; // ✅ inlinedA frequent version of this bug is a helper such as getEnv("NEXT_PUBLIC_API_URL") that works on the server and silently returns undefined in client components.
Which .env file wins?
Next.js looks for a variable in this order and stops at the first match:
process.env— variables already set in your shell or on your host.env.$(NODE_ENV).local— for example.env.production.local.env.local— skipped whenNODE_ENVistest.env.$(NODE_ENV)— for example.env.production.env
Keep .env*.local out of git and commit a .env.example instead — the .env.example generator builds one from your real file.
NEXT_PUBLIC_ variables in Docker and CI
Because the value is baked in during next build, it has to exist while the image is built. Setting it only when the container starts does nothing for the client bundle.
# Dockerfile
ARG NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
RUN npm run build
# build it with the value
docker build --build-arg NEXT_PUBLIC_API_URL=https://api.example.com -t myapp .The same applies to CI and hosting platforms: the variable must be available at build time, not only at runtime. Vercel passes project variables to the build by default; on Docker-based platforms look for the build-time option on the variable.
What should be public?
Keys that are designed to be in the browser:
- Public Stripe publishable key (
pk_*) - Supabase URL + anon key (RLS protects data)
- Firebase client config
- Analytics / tracking IDs (Mixpanel, PostHog, Segment)
- The site's own URL for generating absolute links
What should NEVER be public
- Database URLs (credentials are in the string)
- Stripe secret key (
sk_*) - Supabase service-role key
- Firebase Admin SDK credentials
- OpenAI / Anthropic API keys
- OAuth client secrets
- JWT signing keys
If you're unsure, run your .env through the leak checker — it flags every key that matches a known secret pattern. If any of those have NEXT_PUBLIC_ prefixes, fix immediately.
Common mistakes
Using the key inside a client component without the prefix
"use client";
// ❌ undefined in the browser — silent failure
const url = process.env.API_URL;
// ✅ renamed with prefix — works
const url = process.env.NEXT_PUBLIC_API_URL;Prefixing a secret by accident
# ❌ ships STRIPE_SECRET_KEY to every browser
NEXT_PUBLIC_STRIPE_SECRET_KEY=sk_live_...Yes, this happens. Code review for it. The leak checker catches sk_live_* patterns specifically.
Changing the value without rebuilding
You updated NEXT_PUBLIC_API_URL in the Vercel dashboard. Nothing changes. Because the value is baked into the bundle, you need to redeploy (even a no-op deploy works).
Forgetting server vs client semantics in Next.js App Router
In the App Router, "use client" components get the client bundle rules. Server components get the full env. Mixing them in one file is where most bugs come from — keep env access in server components and pass values down as props.
A safe default pattern
// lib/env.ts — server
import { z } from 'zod';
const schema = z.object({
DATABASE_URL: z.string().url(),
STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
});
export const env = schema.parse(process.env);
// lib/env.client.ts — client
const schema = z.object({
NEXT_PUBLIC_SITE_URL: z.string().url(),
NEXT_PUBLIC_STRIPE_PK: z.string().startsWith('pk_'),
});
export const env = schema.parse(process.env);Two schemas, two imports, no way to accidentally ship a server key to the browser.
Need different values per environment without rebuilding?
If one Docker image has to be promoted from staging to production, don't use NEXT_PUBLIC_ for values that differ. Read them on the server at request time and hand them to the client as props or through a small provider. In the App Router, a server component that renders dynamically reads process.env at runtime:
// app/(dashboard)/layout.tsx — server component
import { connection } from "next/server";
import { ClientConfig } from "@/components/client-config"; // your own context provider
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
await connection(); // render per request so env is read at runtime, not build time
return <ClientConfig apiUrl={process.env.API_URL!}>{children}</ClientConfig>;
}This makes everything below that layout render per request, so apply it to the smallest layout that needs the value.
Common NEXT_PUBLIC_ variables — cheat sheet
| Variable | Typical value | OK in the browser? |
|---|---|---|
NEXT_PUBLIC_SITE_URL / NEXT_PUBLIC_APP_URL | https://myapp.com | Yes — used for canonical links and OG tags |
NEXT_PUBLIC_API_URL | https://api.myapp.com | Yes — the URL is public by nature |
NEXT_PUBLIC_SUPABASE_URL + NEXT_PUBLIC_SUPABASE_ANON_KEY | project URL + anon key | Yes — Row Level Security protects the data |
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY | pk_live_… | Yes — publishable keys are built for browsers |
NEXT_PUBLIC_POSTHOG_KEY + NEXT_PUBLIC_POSTHOG_HOST | project key + host URL | Yes — client-side analytics keys |
NEXT_PUBLIC_GA_ID | G-XXXXXXXXXX | Yes |
NEXT_PUBLIC_CAPTCHA_SITE_KEY (Turnstile, hCaptcha, reCAPTCHA) | site key | Yes — the site key is public; the secret key stays on the server |
NEXT_PUBLIC_VERCEL_ENV | production / preview | Yes — only exists if Vercel's system variables are exposed |
NEXT_PUBLIC_…_SECRET_KEY, …_PRIVATE_KEY | — | Never — rename it and keep it server-only |
FAQ
Why is my NEXT_PUBLIC_ variable still undefined?
Next.js inlines NEXT_PUBLIC_ variables at build time, not at runtime. If you added it after starting the dev server, restart. If you added it after deploying, you need to rebuild — changing the value in the host's dashboard won't update an existing build.
Can I expose NEXT_PUBLIC_SUPABASE_ANON_KEY safely?
Yes. The anon key is designed to be public — Row Level Security protects your data. The service-role key is the one you must keep server-only. Same pattern for Firebase, Stripe publishable keys, Mixpanel tokens.
Does NEXT_PUBLIC_ work in server components?
Yes, it works everywhere. It just also works in the browser. Non-prefixed variables only exist on the server.
Is it safe to put an API key in a NEXT_PUBLIC_ variable?
Only if the key is meant for browsers — publishable, anon or analytics keys. Anything bundled with NEXT_PUBLIC_ is readable by every visitor in the JavaScript bundle, so a key that can read private data or spend money must stay server-only.
Why does process.env[name] return undefined in a client component?
Next.js replaces the exact text process.env.NEXT_PUBLIC_NAME at build time. A dynamic lookup such as process.env[name], or copying process.env into another variable, can't be matched, so nothing is inlined and the browser gets undefined. Write the full variable name in your code.
How do I check which values ended up in my build?
Search the built client files for the value, for example grep -r "https://api.example.com" .next/static after next build, or open the Sources tab in your browser's dev tools. If you can find a secret there, it was exposed — rotate it.
Do Vercel system variables need the NEXT_PUBLIC_ prefix?
To read them in the browser, yes. Vercel's system variables such as VERCEL_ENV are server-side by default. Enable "Automatically expose System Environment Variables" in the project settings and use the NEXT_PUBLIC_ versions, for example NEXT_PUBLIC_VERCEL_ENV.