Skip to content
xecret
Sign in

Next.js environment variables: what NEXT_PUBLIC_ really does, and the three ways secrets end up in the browser bundle

The inlining model for the App Router: build time versus runtime, which .env file wins, the server-only boundary, and how to grep what actually shipped.

The xecret teamPlayxoft8 min read
On this page

Most Next.js teams can recite the rule — prefix it with NEXT_PUBLIC_ and it reaches the browser, leave the prefix off and it does not. The rule is correct and it is also not enough, because it describes a naming convention while the thing that actually decides is a build-time text substitution and a module graph. This is about the App Router in Next.js 16 with React Server Components; the mechanics differ from the Pages Router in ways that matter.

The prefix is a substitution, not a permission

NEXT_PUBLIC_ANALYTICS_ID is not "a variable the browser is allowed to read". During next build, the bundler finds every literal occurrence of process.env.NEXT_PUBLIC_ANALYTICS_ID in code destined for the client and replaces the expression with the value as a string constant. There is no lookup at runtime. There is no process.env in the browser. There is a JavaScript file with your value typed into it.

Three consequences follow, and each one surprises somebody:

The value is public forever, not merely public now. It is baked into a static asset. Every previously deployed build that is still reachable — a preview deployment, a CDN edge holding an old chunk, a customer's browser cache — contains the value you had at that build. Changing it later publishes a new constant; it does not retract the old one. If you ever prefixed a real credential, rotation is the only remedy.

Only literal references are substituted. The bundler is doing textual replacement, so process.env.NEXT_PUBLIC_X is replaced and process.env[name] is not, and neither is a destructured const { NEXT_PUBLIC_X } = process.env. People discover this when a dynamic lookup returns undefined in production and works in next dev, where a real Node process with a real environment is running underneath.

Unprefixed variables in client code become an empty string. Next.js does not leave process.env.STRIPE_SECRET_KEY alone in a client bundle — it substitutes an empty string. This is a genuinely good default, and it is also why the failure mode of leaking is usually a confusing 401 rather than a loud error.

Build time and runtime are different moments

The second concept people conflate. There are two distinct occasions on which a value can be read, and which one applies decides where the value has to exist.

Read atApplies toWhere the value must be present
BuildEvery NEXT_PUBLIC_ reference, and anything read while prerendering a static routeThe environment of the machine running next build
Requestprocess.env in a dynamically rendered Server Component, Route Handler or Server ActionThe environment of the running server, at the moment of the request

This is what bites on a serverless or edge deploy. You set a variable in your platform's dashboard, redeploy, and the server-side code picks it up while the client-side one stubbornly shows the old value — because the client-side one was frozen into a chunk during the build that ran before your change, and the platform's dashboard variables were injected into the runtime, not the build. The same asymmetry is why promoting a single Docker image between staging and production works for server secrets and silently does not work for NEXT_PUBLIC_ values: one image, one build, one set of baked constants.

If you need a value to be genuinely per-deployment on the client, it cannot be an inlined constant. It has to be fetched — from a Route Handler, or read on the server during dynamic rendering and passed down. Which brings us, shortly, to the third leak.

Which file wins

Next.js resolves each variable by looking in these places in order and stopping at the first hit:

  1. process.env — the real environment of the process
  2. .env.$(NODE_ENV).local
  3. .env.local — skipped entirely when NODE_ENV is test
  4. .env.$(NODE_ENV)
  5. .env

The first line is the one worth internalising: the real process environment beats every file. That is why an injector works without any code change — xecret run -- next dev populates the process environment before Next.js starts, so the files become unnecessary rather than overridden.

NODE_ENV takes only production, development or test, and if you have not set it Next.js assigns one: development for next dev, production for every other command. So .env.production is loaded during next build and next start on your own laptop too, which trips people who assumed the filename referred to their hosting environment rather than to NODE_ENV.

The three ways a secret reaches the browser

1. The prefix applied to something that should not have it

The mundane one, and the most common. It happens for a specific and sympathetic reason: a value read fine on the server, someone moved the code into a Client Component, the value became an empty string, and adding NEXT_PUBLIC_ made the error go away. The prefix is now doing exactly what it promises, and the promise was the wrong one.

The tell is semantic, not syntactic. Ask of every prefixed variable: if I posted this in a public issue, what would I have to rotate? Publishable keys, project ids, public API base URLs and analytics tokens survive that question. Anything with secret, private, password, token or a service account in it does not.

2. A server module pulled into the client graph

In the App Router the boundary is the module graph, not the file's location. A file is server-only until something reachable from a 'use client' module imports it — at which point it and everything it imports are compiled for the browser.

Next.js blanks unprefixed process.env reads in that bundle, so this usually does not leak the credential itself. What it does leak is everything else in the module: internal hostnames, undocumented endpoints, query shapes, table names, the structure of your admin API. And there is one shape where the actual value does ship — a literal in the source:

TypeScript
// lib/payments.ts — the fallback is a string literal, not an env read.
// Nothing blanks it. If this module reaches the client graph it ships verbatim.
const key = process.env.STRIPE_SECRET_KEY ?? 'sk_live_51H8xExampleNotARealKey';

The fix is to make the boundary explicit rather than remembered:

TypeScript
// lib/payments.ts
import 'server-only';

export async function chargeCard(amount: number) {
  const key = process.env.STRIPE_SECRET_KEY;
  // …
}

server-only has no runtime behaviour. Importing it makes the build fail — at build time, with a stack trace naming the offending import chain — the moment anything in the client graph reaches this module. That failure is the entire product. Put it at the top of every module that touches a credential, a database handle or a signing key, and the import boundary stops depending on whether the person doing the refactor happened to remember it.

3. A config object serialised into props

This is the one that actually leaks values in a modern App Router codebase, and no prefix rule protects you from it.

Props passed from a Server Component to a Client Component are serialised into the RSC payload, which is embedded in the HTML document the browser receives. Not fetched, not derived — inlined in the response, viewable with view source. So a Server Component that reads a secret and passes an object containing it across the boundary has published it, whatever the variable was called.

TSX
// app/settings/page.tsx — a Server Component
import { ClientPanel } from './client-panel';

export default async function Page() {
  // Reading these on the server is fine. Handing the object to a Client
  // Component is not: every field is serialised into the HTML.
  const config = {
    region: process.env.AWS_REGION,
    apiBase: process.env.NEXT_PUBLIC_API_BASE,
    signingKey: process.env.WEBHOOK_SIGNING_KEY,
  };

  return <ClientPanel config={config} />;
}

The habit that prevents it is to pass the result of using a secret rather than the secret, and to construct props field by field rather than spreading a config object across the boundary:

diff
-  return <ClientPanel config={config} />;
+  // Explicit fields, so adding a secret to `config` later cannot silently
+  // start shipping it to the browser.
+  return <ClientPanel region={config.region} apiBase={config.apiBase} />;

The same rule applies to database rows. <UserCard user={user} /> where user came straight from an ORM sends the password hash and the session token to the browser, because the serialiser has no opinion about which columns you meant. React's experimental taint APIs exist to catch this, but the durable practice is narrower: never spread a server-side object into client props.

Checking what actually shipped

Do not reason about this. Grep it. After a build, two places matter, and checking only the first is the common mistake.

bash
npx next build

# 1. The static chunks — where an inlined NEXT_PUBLIC_ value lands.
grep -rl 'sk_live_' .next/static/

# 2. The rendered HTML — where serialised props land. Start the app first.
npx next start &
curl -s http://localhost:3000/settings | grep -o 'sk_live_[A-Za-z0-9]\{6\}'

Search for the value, or a distinctive prefix of it, rather than for the variable's name — the name is exactly what disappears during inlining. Run both checks in CI against a known-bad canary string and you have a regression test for the whole class of problem.

Where xecret fits

Clearly marked so you can skip it. None of the above is solved by where the values are stored — the inlining model is the same whichever tool fills process.env. What a tool can do is make the build-time step unmissable and the values themselves accountable: with xecret you run xecret run -- next build so the build has the environment it needs, keep one value per environment rather than a .env.local per laptop, and get an audit record of every read. The Next.js guide covers the deployment patterns, the quickstart is five minutes, and the trust model states plainly what we can and cannot see — server-side envelope encryption, so we can technically decrypt, which is the trade that makes team sharing and CI tokens work without a key exchange. It is pre-alpha; the pricing page says what each plan includes and what, if anything, it costs on the day you read this.

Keep reading

Get your secrets out of version control.

Create a project, import the .env file you already have, and run your app through the CLI. It takes about a minute, and the free tier does not ask for a card.

No credit card · AGPL-3.0 server · MIT CLI · Self-host it for free