# Environment variables on Vercel, without leaking them into the browser

> Next.js inlines env reads at build time, so one import can ship a server key to every visitor. Here is the boundary that prevents it and the checks that prove it held.

A developer adds analytics. The SDK needs a key, the key is in `.env.local`, the
component is a client component, so:

```tsx
"use client";

export function Tracker() {
  const client = new Analytics(process.env.ANALYTICS_API_KEY);
  // ...
}
```

It works locally. It works in preview. It works in production. Six weeks later
somebody opens devtools, searches the JavaScript bundle for `ANALYTICS`, and
finds the key sitting in plain text: served to every visitor since the day it
shipped, cached by every CDN in between.

Nothing errored. Nothing warned. That is what makes this the most common secret
leak in a Next.js codebase.

## Why it happens

Next.js does not read `process.env` in the browser: there is no environment
there. What it does instead is **textually replace** `process.env.FOO` with the
value at build time, in any module that ends up in a client bundle. The
replacement is not conditional on the variable being marked public. The
`NEXT_PUBLIC_` prefix is a *convention for humans*, not a lock.

So the rule is simpler and harsher than most people assume:

> If a module reaches a client bundle, every `process.env` read inside it is
> published.

And "reaches a client bundle" is transitive. A Server Component that imports a
utility file is fine. A client component that imports the same utility file
publishes every env read in it. You can be leaking a key from a file that has no
`"use client"` anywhere in it.

## The wrong way, in three flavours

```tsx
// 1. The obvious one
"use client";
const key = process.env.STRIPE_SECRET_KEY;

// 2. The transitive one: src/lib/config.ts has no "use client",
//    but a client component imports it, so it is bundled
export const config = { stripeKey: process.env.STRIPE_SECRET_KEY };

// 3. The "I renamed it so the warning went away" one
const key = process.env.NEXT_PUBLIC_STRIPE_SECRET_KEY;
```

The third is the worst, because it looks like a fix. Renaming a secret to
`NEXT_PUBLIC_` does not make it public-safe; it makes it public.

## The right way: one server-only boundary

Read secrets in a Server Component, a Route Handler or a Server Action, and pass
the *derived result* to the client, never the credential.

```ts
// src/lib/env.ts: the only place required variables are declared
export const REQUIRED_ENV = [
  "NEXT_PUBLIC_APP_URL",
  "STRIPE_SECRET_KEY",
] as const;

export function env(key: RequiredEnvKey): string {
  const value = process.env[key];
  if (value === undefined || value.trim() === "") {
    throw new Error(`Missing required environment variable ${key}. See docs/onboard.md`);
  }
  return value;
}
```

```ts
// src/app/api/checkout/route.ts: server side, key never leaves the function
import Stripe from "stripe";
import { env } from "@/lib/env";

export const runtime = "nodejs";

export async function POST(request: Request) {
  const stripe = new Stripe(env("STRIPE_SECRET_KEY"));
  const session = await stripe.checkout.sessions.create({ /* ... */ });
  return Response.json({ url: session.url });   // a URL, not a key
}
```

```tsx
// the client gets a function to call, not a credential
"use client";

export function CheckoutButton() {
  async function start() {
    const response = await fetch("/api/checkout", { method: "POST" });
    const { url } = await response.json();
    window.location.href = url;
  }
  return <button onClick={start}>Upgrade</button>;
}
```

For values that genuinely are public (a publishable key, an analytics host, the
app URL) use the `NEXT_PUBLIC_` prefix and write the read out **literally**:

```tsx
const publishable = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY;   // works
const publishable = process.env[keyName];                             // does not
```

The build-time replacement is textual, so a computed lookup is `undefined` in the
browser. This surprises people every single time.

## Setting them on Vercel

Vercel scopes each variable to Production, Preview and Development
independently. A variable set only in Development fails the **Production
build**, which reads as a compile error, not a config error, and sends people
hunting through their code.

```bash
bunx vercel env ls
bunx vercel env add STRIPE_SECRET_KEY production
```

`env add` prompts for the value on stdin. Never pass a secret as a command-line
argument: it lands in shell history, in your terminal scrollback, and in any
agent transcript watching the session.

Two more that cost people an afternoon each:

- **`NEXT_PUBLIC_*` values are baked in at build time.** Changing one in the
  dashboard does nothing until the next deploy. If a public value looks stale,
  this is why.
- **`NEXT_PUBLIC_APP_URL` must match the deployment.** It is the metadata base
  for canonical URLs and OG images. Leave it as `http://localhost:3000` in
  production and every link preview breaks silently.

## Prove it, three ways

**One:** every new variable lands in three places in the same change,
`.env.example` with a placeholder, `src/lib/env.ts`, and `docs/onboard.md`. If
it is in only two, the next clone fails.

**Two:** check the built bundle. This is the only check that cannot lie:

```bash
bun run build
grep -ril "sk_live" .next/static/ || echo "clean"
```

Do it for each secret's distinctive prefix. A hit means rotate immediately:
the value is in every CDN cache and every browser that loaded the page.

**Three:** let the guards do it live. This repo ships `env-leak-detector`
(PreToolUse Bash) and `env-leak-detector-write` (PostToolUse Edit), which
between them block a credential in a shell command, a dump of `.env`, a secret
literal written into source, and a non-public `process.env` read inside a file
containing `"use client"`. They catch the leak in the turn it is created rather
than six weeks later.

## If a key has already shipped

In order, and do not reverse them:

1. **Rotate at the provider.** Deleting the line does not un-publish the value.
2. Update every Vercel scope with the new value.
3. Redeploy, so the old bundle stops being served.
4. Fix the code so the read is server-side.
5. Check the provider's audit log for use between exposure and rotation.

Removal is not rotation. That sentence is the whole lesson.

---

Agentic Boilerplate: A Next.js repo your agent already knows. Free during launch, then $99 once.

- Site map for agents: https://agenticboilerplate.com/llms.txt
- Public API: https://agenticboilerplate.com/openapi.json
- Contact: agenticstudio@gmail.com
