Vibe Coding, Security
Next.js Environment Variables Exposed in the Browser: Why It Happens and How to Fix It

If you opened your deployed site's JavaScript bundle, searched for your API key, and found it sitting there in plain text, your Next.js environment variables are exposed — and the culprit is almost always the
This post explains exactly how the leak happens, how to confirm which secrets are exposed, and how to fix a leaked API key without breaking the parts of your app that legitimately need the value.
NEXT_PUBLIC_ prefix. Any variable you name NEXT_PUBLIC_SOMETHING gets inlined into the client bundle at build time, which means it ships to every visitor's browser as a hard-coded string.This post explains exactly how the leak happens, how to confirm which secrets are exposed, and how to fix a leaked API key without breaking the parts of your app that legitimately need the value.
Join the Vibe Coding Community
Swap builds, trade feedback, and ship faster alongside other people vibe coding.
The One-Line Answer
Any environment variable prefixed with
If you've put a database URL, a private API key, or a signing secret behind a
NEXT_PUBLIC_ is embedded directly into the browser JavaScript bundle at build time and is fully readable by anyone who opens DevTools. It is not a secret. Server-only secrets must never carry that prefix — read them without it, in server-side code (Route Handlers, Server Components, getServerSideProps, API routes) only.If you've put a database URL, a private API key, or a signing secret behind a
NEXT_PUBLIC_ name, treat it as compromised: rotate it, then move it to a server-only variable.Why NEXT_PUBLIC_ Variables End Up in the Browser
Next.js splits environment variables into two worlds based purely on the name.
A variable without the prefix — say
A variable with the prefix —
That inlining is the whole mechanism behind the leak. Two consequences follow directly from it:
The value is visible to everyone. Open DevTools, look at the JS chunks, Ctrl+F for the key — it's right there. Minification does not hide it; it's a plain string.
The value is frozen at build time. After the build, the app no longer responds to changes in that variable. Rotating the secret in your host's dashboard does nothing until you rebuild and redeploy. (This is also why teams that need per-environment values reach for runtime config instead — see the fix section.)
The prefix exists for legitimately public values: a Stripe publishable key, a public analytics ID, a public Supabase anon key that's protected by row-level security. The danger is using it out of habit — or copy-pasting a
A variable without the prefix — say
DATABASE_URL — is only available in the Node.js runtime. It exists in Server Components, Route Handlers, API routes, and getServerSideProps. It never reaches the client.A variable with the prefix —
NEXT_PUBLIC_API_KEY — is treated as public. During next build, Next.js performs a find-and-replace across all code that will be sent to the browser: every reference to process.env.NEXT_PUBLIC_API_KEY is swapped for the literal value. There is no lookup at runtime. The string is baked into the bundle.That inlining is the whole mechanism behind the leak. Two consequences follow directly from it:
The value is visible to everyone. Open DevTools, look at the JS chunks, Ctrl+F for the key — it's right there. Minification does not hide it; it's a plain string.
The value is frozen at build time. After the build, the app no longer responds to changes in that variable. Rotating the secret in your host's dashboard does nothing until you rebuild and redeploy. (This is also why teams that need per-environment values reach for runtime config instead — see the fix section.)
The prefix exists for legitimately public values: a Stripe publishable key, a public analytics ID, a public Supabase anon key that's protected by row-level security. The danger is using it out of habit — or copy-pasting a
.env example — for something that was supposed to stay private.How the Leak Actually Happens (the Common Patterns)
Most exposed-secret incidents in Next.js come from a small set of mistakes:
1. Prefixing a Secret to "Make It Work" on the Client
A developer tries to call a third-party API from a component, hitsundefined because the unprefixed variable isn't available in the browser, and "fixes" it by renaming API_SECRET to NEXT_PUBLIC_API_SECRET. The error goes away — and the secret is now in the bundle. The real fix was to move the API call to the server, not to expose the key.2. Blanket-Prefixing an Entire .env File
Some teams prefix everything so they never have to think about which side needs what. That turns every secret into a public value. The prefix is a security boundary, not a convenience toggle.3. A Secret in Server Code That's Imported by a Client Component
If a module readsprocess.env.SOME_SECRET and that module gets imported (even transitively) into a Client Component, the bundler may pull the reference into client code. In the App Router, marking a file "use client" and reading a non-prefixed secret in it won't magically protect you — keep secret reads in modules that only ever run on the server.4. Committing .env.local to Git
Separate from the bundle, secrets leak through version control..env.local is git-ignored by default in a create-next-app project, but custom setups and .env / .env.production files often aren't. A key in git history is exposed even if it never reached the browser.How to Confirm Which Variables Are Exposed
Before fixing, find out what actually leaked. Don't guess.
Search your production bundle. Build the app and grep the client output for a known secret value or for the prefix:
Anything that matches is public. If you find a genuinely secret value in there, it's compromised.
Check it live. On the deployed site, open DevTools → Sources (or Network → JS files), and search the loaded scripts for the key. If it's visible to you, it's visible to every visitor and every scraper.
Audit your git history for committed env files:
If a secret was ever committed, rotating it is not optional — history is forever unless you rewrite it.
Search your production bundle. Build the app and grep the client output for a known secret value or for the prefix:
next build
# App Router output lives in .next/static
grep -r "NEXT_PUBLIC_" .next/static/ | head
# Or search for a specific secret's value:
grep -r "sk_live_" .next/static/Anything that matches is public. If you find a genuinely secret value in there, it's compromised.
Check it live. On the deployed site, open DevTools → Sources (or Network → JS files), and search the loaded scripts for the key. If it's visible to you, it's visible to every visitor and every scraper.
Audit your git history for committed env files:
git log --all --full-history -- "*.env*"If a secret was ever committed, rotating it is not optional — history is forever unless you rewrite it.
The Fixes
Fix 1: Rotate Anything That Leaked, Immediately
Once a secret has been in a client bundle or in git, assume it's captured. Rotate the key at the provider, deploy the new value as a server-only variable (no prefix), and invalidate the old one. Rotation comes first because every later step assumes the exposed value is already dead.Fix 2: Move the Secret to Server-Only Code
The value your client component needed almost never needs to be on the client — it needs the result of using it. Keep the secret on the server and expose only what the browser requires.App Router, using a Route Handler as a thin proxy:
// app/api/quote/route.ts (runs on the server only)
export async function GET() {
const res = await fetch("https://api.example.com/quote", {
headers: { Authorization: `Bearer ${process.env.API_SECRET}` }, // no prefix
});
const data = await res.json();
return Response.json(data);
}// app/components/Quote.tsx ("use client")
"use client";
import { useEffect, useState } from "react";
export function Quote() {
const [quote, setQuote] = useState<string>();
useEffect(() => {
fetch("/api/quote").then((r) => r.json()).then((d) => setQuote(d.text));
}, []);
return <p>{quote}</p>;
}The browser calls your own endpoint; the secret stays in Node. The same pattern works with a Server Component that fetches directly and passes only rendered data down.
Fix 3: Reserve NEXT_PUBLIC_ for Values That Are Genuinely Public
Before you add the prefix, ask: is this value safe to print on a billboard? Publishable keys, public project URLs, and analytics IDs pass that test. Anything that grants write access, reads private data, or signs tokens does not. When in doubt, leave the prefix off and proxy through the server.Fix 4: If You Need Runtime (Not Build-Time) Values, Read Them Server-Side
BecauseNEXT_PUBLIC_ values are frozen at build, teams that ship one Docker image across staging and prod can't use them for per-environment config. The fix is to read the variable at request time in server code — a Server Component, Route Handler, or getServerSideProps — where process.env is evaluated live, and pass the needed value to the client as props. This keeps one build reusable across environments without inlining anything into the bundle.Fix 5: Lock Down Your Env Files
Make sure.gitignore covers every local env file:.env
.env.local
.env*.local
.env.productionKeep a committed
.env.example with empty values for onboarding, and load real secrets from your host's encrypted environment settings — never from a file in the repo.A Repeatable Order of Operations
When you suspect a secret is exposed in a Next.js app:
1.
2. Search the live bundle in DevTools for any key you consider secret.
3.
4. Rotate every secret that turns up in steps 1–3. Treat them as burned.
5. Move each real secret to an unprefixed, server-only variable and proxy client needs through a Route Handler or Server Component.
6. Fix
The mental model that prevents all of this: the
1.
grep -r "NEXT_PUBLIC_" .next/static/ after a build — see what's actually inlined.2. Search the live bundle in DevTools for any key you consider secret.
3.
git log --all -- "*.env*" — check whether env files were ever committed.4. Rotate every secret that turns up in steps 1–3. Treat them as burned.
5. Move each real secret to an unprefixed, server-only variable and proxy client needs through a Route Handler or Server Component.
6. Fix
.gitignore and move secrets into your host's encrypted env settings.The mental model that prevents all of this: the
NEXT_PUBLIC_ prefix is a public-address label, not a lock. If a value should stay private, it should never wear that label, and it should never be read anywhere the bundler can follow into client code.Find Every Exposed Secret in Your Repo on the First Import
Grepping your own bundle catches the leaks you already suspect. It won't catch the
Differ connects to your GitHub repo and runs a full security scan on first import — no live traffic, no setup. Its code scan flags exposed environment variables, secrets that reach the client bundle, and env-file misconfigurations, and points you at the exact line and the fix for each one.
Connect your repo and get the free report in seconds — it'll show you which of your secrets are already public before an attacker finds them first.
NEXT_PUBLIC_ you forgot about, the secret that's transitively imported into a client component, or the API key sitting in your git history from three months ago.Differ connects to your GitHub repo and runs a full security scan on first import — no live traffic, no setup. Its code scan flags exposed environment variables, secrets that reach the client bundle, and env-file misconfigurations, and points you at the exact line and the fix for each one.
Connect your repo and get the free report in seconds — it'll show you which of your secrets are already public before an attacker finds them first.