guide9 min readupdated

What is NEXT_PUBLIC_ in Next.js env variables?

The NEXT_PUBLIC_ prefix decides whether an env variable is shipped to the browser or kept on the server. Here's how it works, when to use it, and the traps.

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 + server

How 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:

  1. 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.
  2. Non-public variables aren't bundled. A client component referencing process.env.DATABASE_URL will just get undefined at runtime. Next.js doesn't warn — it silently resolves to undefined.

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; // ✅ inlined

A 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:

  1. process.env — variables already set in your shell or on your host
  2. .env.$(NODE_ENV).local — for example .env.production.local
  3. .env.local — skipped when NODE_ENV is test
  4. .env.$(NODE_ENV) — for example .env.production
  5. .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

VariableTypical valueOK in the browser?
NEXT_PUBLIC_SITE_URL / NEXT_PUBLIC_APP_URLhttps://myapp.comYes — used for canonical links and OG tags
NEXT_PUBLIC_API_URLhttps://api.myapp.comYes — the URL is public by nature
NEXT_PUBLIC_SUPABASE_URL + NEXT_PUBLIC_SUPABASE_ANON_KEYproject URL + anon keyYes — Row Level Security protects the data
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEYpk_live_…Yes — publishable keys are built for browsers
NEXT_PUBLIC_POSTHOG_KEY + NEXT_PUBLIC_POSTHOG_HOSTproject key + host URLYes — client-side analytics keys
NEXT_PUBLIC_GA_IDG-XXXXXXXXXXYes
NEXT_PUBLIC_CAPTCHA_SITE_KEY (Turnstile, hCaptcha, reCAPTCHA)site keyYes — the site key is public; the secret key stays on the server
NEXT_PUBLIC_VERCEL_ENVproduction / previewYes — 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.

Related reading

Related tools

Continue reading