ShipWatchFree scan

NEXT_PUBLIC_ means public: securing keys in v0 and Next.js apps

Any environment variable that starts with NEXT_PUBLIC_ is written into the JavaScript your visitors download. v0 generates Next.js apps, so the same rule applies to every app it builds. This page shows how to prove it, and how to move a secret behind a server route without breaking the feature.

Short answer: Yes. Next.js replaces process.env.NEXT_PUBLIC_* with the literal value during next build, so it is in the browser bundle. Variables without the prefix stay on the server. Put secret keys in an unprefixed variable and use them only in Route Handlers, Server Actions or Server Components.

This is the Next.js version of the VITE_ variables guide. If you already know which provider's key leaked, start with the rotate-first playbook and come back here to fix the code.

What Next.js does with NEXT_PUBLIC_

Next.js loads .env, .env.local, .env.production and friends into process.env. By default those values exist only on the server. The NEXT_PUBLIC_ prefix tells Next.js to inline the value: at build time it replaces every process.env.NEXT_PUBLIC_NAME reference with a hard-coded string, which then ships in any JavaScript sent to the browser. Two consequences beginners miss:

v0 and other AI builders reach for the prefix when client code throws "undefined" for an env var. Adding NEXT_PUBLIC_ makes the error go away by making the key public.

Check it yourself [manual]

Locally, build and search the client output:

npm run build
grep -rE "sk-proj-|sk_live_|sk-ant-|sb_secret_" .next/static/

.next/static/ holds the chunks the browser downloads. A match there means the key is public. On the live site, open DevTools, go to Sources (Chrome) or Debugger (Firefox), open the _next/static/chunks files and search for the start of your key. Also search your code for the prefix itself:

grep -rn "NEXT_PUBLIC_" --include=*.ts --include=*.tsx --include=*.js .

Classify every hit. Anything that could spend money, write data or bypass access rules is a secret.

How to fix it

1. Rotate the exposed key [manual]

Do this first. Renaming the variable doesn't recall copies already downloaded. The provider guides cover it: OpenAI, Stripe, or the general rotation order.

2. Move the call into a Route Handler

Put the new key in an unprefixed variable (OPENAI_API_KEY) in .env.local and in your Vercel project's Environment Variables. Then create app/api/chat/route.ts:

export async function POST(req: Request) {
  const { message } = await req.json();
  const r = await fetch("https://api.openai.com/v1/chat/completions", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ model: "gpt-4o-mini", messages: [{ role: "user", content: message }] }),
  });
  return Response.json(await r.json());
}

The client component now calls fetch("/api/chat", ...) and never sees the key. Add an auth check and a rate limit before you ship, or anyone can use your route as a free proxy.

3. Or use a Server Action

For form-style work, a function in a file marked "use server" runs on the server and can read process.env.OPENAI_API_KEY. The client calls it like a function, but only the arguments and return value cross the network. Never return the key or the raw provider response containing it.

4. Keep secrets out of Client Components

Put import "server-only" at the top of modules that read secrets. If a Client Component ever imports one, the build fails instead of leaking.

Find every NEXT_PUBLIC_ environment variable in this Next.js app. Keep only values that are public by design (site URL, Supabase URL and anon key, Stripe publishable key, analytics IDs). For every secret one, rename it without the NEXT_PUBLIC_ prefix, move the code that uses it into a Route Handler under app/api or a Server Action, require a logged-in user there, add import "server-only" to modules that read it, and update client code to call the new endpoint.

Make it stick

What the free scan checks here (and what it doesn't)

The scan reads your deployed HTML plus up to 25 linked scripts and flags Stripe sk_live_/rk_live_, OpenAI, Anthropic, AWS AKIA/ASIA, Google AIza, and Supabase service_role and sb_secret_ keys. A Supabase anon key or project URL is reported as info only. It also checks /.env, /.git/HEAD, HTTPS and its redirect, and four headers: CSP, HSTS, X-Frame-Options or frame-ancestors, and nosniff.

It doesn't read your server code, so it can't see a Route Handler that has no auth check or one that hands the key back in its response. Review those by hand [manual].

FAQ

Is a variable without NEXT_PUBLIC_ ever sent to the browser?
Not by the env loader. Next.js only inlines NEXT_PUBLIC_ variables into client code. But if a Server Component passes a secret as a prop to a Client Component, or your code returns it in a response, it still reaches the browser.
I removed NEXT_PUBLIC_ from the name. Is the old key safe now?
No. Every deployment built while it had the prefix shipped the value, and old builds or cached copies may still exist. Rotate the key with the provider, then redeploy with the new key in an unprefixed variable.
Does the free scan understand Next.js?
It doesn't need to. It reads your live HTML and up to 25 linked scripts, including the _next/static chunks, and flags known key formats: Stripe, OpenAI, Anthropic, AWS, Google and Supabase secret keys. It can't recognize a custom secret with no known prefix.

Scan your app free in 30 seconds

Paste your live URL for a plain-English report with fix prompts. If you keep shipping, weekly monitoring re-checks it: Solo $19/mo or Pro $49/mo.

Scan my app free