Skip to main content
ASoc
Tutorial

Which Environment Variables Reach the Browser, and When That Is Decided

We grepped the build: two public values in one client file each, three secrets in none. The trap is not the prefix rule but that the value freezes at build time.

The ASoc Team11 min read

Prefix a variable with NEXT_PUBLIC_ and its value is compiled into the browser bundle; leave the prefix off and it stays on the server. That rule is in every guide and it is true. We measured it anyway, and the measurement surfaced the part that actually bites: the value is frozen at build time, including inside a response header, so changing it in your hosting dashboard does nothing until you redeploy.

Here is the experiment, its output, and the four classes of environment variable this codebase ended up with — one of which is a public variable that never reaches the browser at all, because it is baked into a security header instead.

The experiment

Build with marker values in place of the real ones, then grep the two things a visitor can actually fetch: the client JavaScript, and the prerendered HTML.

NEXT_PUBLIC_SUPABASE_URL="https://leaktest12345.supabase.co" \
NEXT_PUBLIC_SUPABASE_ANON_KEY="anonleaktest67890" \
SUPABASE_SERVICE_ROLE_KEY="serviceleaktest99999" \
RESEND_API_KEY="re_leaktest55555" \
npm run build

for v in leaktest12345 anonleaktest67890 serviceleaktest99999 re_leaktest55555; do
  printf "%s → static:%s html:%s\n" "$v" \
    "$(grep -rl "$v" .next/static | wc -l)" \
    "$(grep -rl "$v" .next/server/app --include='*.html' | wc -l)"
done

The result, over a build that emitted 30 client JavaScript files (27 of them chunks):

VariablePrefixClient JS files containing itPrerendered HTML
NEXT_PUBLIC_SUPABASE_URLpublic1 of 300
NEXT_PUBLIC_SUPABASE_ANON_KEYpublic1 of 300
SUPABASE_SERVICE_ROLE_KEYnone00
RESEND_API_KEYnone00

Two things are worth reading off that table. The public values are in exactly one chunk each, not smeared across the bundle — they land in the module that references them, which is why a stray import can move a value into a chunk that loads on every page. And the server secrets are in zero files, which is the assurance you actually want before a launch: not "we followed the rule", but "we looked".

Run this before every launch. It takes a second, it needs no tooling, and it is the only check that observes the artefact rather than the intention. It belongs on the pre-deploy checklist next to a secret scan of the git history.

The part the rule leaves out: when it is decided

Second experiment. Take the build above and start it with a different value in the environment:

NEXT_PUBLIC_SUPABASE_URL="https://runtimevalue999.supabase.co" npx next start

Then look at what is served:

$ grep -o "leaktest12345\|runtimevalue999" .next/static/chunks/*.js | sort -u
.next/static/chunks/35s_vm53ptnq0.js:leaktest12345

$ curl -sI http://localhost:3000/pricing | grep -io "connect-src[^;]*"
connect-src 'self' https://leaktest12345.supabase.co

The build-time value wins in both places. The client chunk still carries it, which is the documented behaviour. The response header still carries it too, which surprises people: our content-security policy derives its connect-src origin from that same variable in next.config.ts, and the config's headers() result is resolved during the build, not per request.

Three consequences, in descending order of how much they hurt:

  1. Rotating a public value requires a redeploy. Changing it in a dashboard changes nothing that has already been built.
  2. A public value that was wrong at build time is wrong everywhere it was used — bundle, prerendered HTML, and any header derived from it — until you rebuild.
  3. A public value that was missing at build time is missing permanently in that deployment. Which is why fallbacks matter more than they look.

Four classes, not two

The public/private split is the type system's view. In practice this repo has four kinds of variable, and they fail differently:

ClassExampleRead atFailure when unset
Public, inlinedNEXT_PUBLIC_SUPABASE_URLBuildBrowser code holds undefined; SDK throws in the client
Public, consumed by configthe same variable, in next.config.tsBuildPolicy falls back to a broad default; nothing errors
Server secret, per requestSUPABASE_SERVICE_ROLE_KEY, LEMONSQUEEZY_WEBHOOK_SECRETRunFeature fails; site keeps serving
Server value on every request pathanything read in the proxyRunSite-wide outage

The second row is the one worth staring at. Our policy computes the allowed origin from the public Supabase URL so it is correct per environment, and falls back if the variable is missing or unparseable:

const supabaseOrigin = (() => {
  try {
    return new URL(process.env.NEXT_PUBLIC_SUPABASE_URL!).origin;
  } catch {
    return "https:";
  }
})();

A missing variable therefore does not break the build — it silently widens connect-src from one host to every HTTPS host. That is a deliberate trade (a broken site is worse than a loose policy) and it is exactly the kind of decision that must be written down, because nothing will ever alert you to it.

The fourth row is the one that takes the site down. Next 16's proxy — what used to be middleware — runs on every matched request, and ours constructs a Supabase client there to refresh the session. Without its two variables, every request errors, including the fully static marketing pages that have nothing to do with auth. The proxy's own post covers that file in detail; the environment lesson is narrower: anything read on the universal request path is a single point of failure for the whole site, so its configuration deserves the same review as its code.

server-only is a real boundary, not a naming convention

Prefixes protect values. They do not protect modules. Nothing about calling a file admin.ts stops a Client Component from importing it — and if it does, the bundler will try to include the module that reads your service-role key, the one credential in this app that bypasses every row-level security policy.

The server-only package makes that a build failure:

import "server-only";
import { createClient as createSbClient } from "@supabase/supabase-js";

/**
 * Service-role Supabase client — SERVER ONLY. Bypasses RLS. Use only in vetted
 * server code (webhook writes, signed-URL issuance). Never import from a Client
 * Component; the `server-only` guard makes such an import fail the build.
 */
export function createAdminClient() {
  return createSbClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.SUPABASE_SERVICE_ROLE_KEY!,
    { auth: { persistSession: false, autoRefreshToken: false } },
  );
}

One import line, and the mistake becomes impossible instead of unlikely. Put it at the top of every module that reads a secret, touches a service-role client, or signs anything. The cost is nothing; the alternative is a code review that has to be right every time.

Note the mixed pair in that call: a public URL and a secret key, side by side. Public and private are properties of individual variables, not of files — a module can legitimately read both.

Two traps in how the values are read

The non-null assertion. process.env.X! tells TypeScript the value is a string. It tells the runtime nothing. When the variable is missing you do not get a clear error at startup; you get undefined handed to an SDK, and a stack trace from inside that SDK several calls later. If you want a clear failure, validate at the edge of the feature and say so:

const secret = process.env.LEMONSQUEEZY_WEBHOOK_SECRET;
const storeId = process.env.LEMONSQUEEZY_STORE_ID;
if (!secret || !storeId) {
  console.error("webhook: LEMONSQUEEZY_WEBHOOK_SECRET/LEMONSQUEEZY_STORE_ID not configured");
  return new Response("internal error", { status: 500 });
}

That fails closed, logs the cause in words, and — crucially — fails only the webhook. A validation that throws at module import would take out every route that transitively imports it.

Empty is not the same as unset. This one reached our production markup:

export function siteUrl(fallback = "https://asoctemplates.com"): string {
  return (process.env.NEXT_PUBLIC_SITE_URL || fallback).replace(/\/+$/, "");
}

The || is load-bearing. With ??, an empty string — the normal state of a variable that exists in the dashboard but was never filled in — passes through as a real value, and every absolute URL on the site becomes root-relative. The trailing-slash strip is the other half of the same bug: a slash on the end produced https://host//dashboard, which stopped matching the canonical URLs Google was crawling, and would have been rejected outright by an auth redirect allow-list that compares exactly.

Mistakes and how they show up

MistakeSymptomFix
NEXT_PUBLIC_ on a secretKey in the bundle, in every cached buildDrop the prefix; rotate the key; rebuild
Rotating a public value without redeployingOld value still served everywhereRedeploy; treat public values as build inputs
process.env.X! with no validationCryptic SDK error, far from the causeCheck and fail closed at the feature edge
?? on an env varEmpty string treated as configuredUse `
Trailing slash in a URL variable//path; canonicals and allow-lists stop matchingStrip it in one shared helper
Secret module with no server-onlyBundler pulls it toward the clientOne import line at the top
Env read inside the proxyOne missing variable takes the whole site downReview the universal path separately
Config-derived headers assumed dynamicPolicy still names the old environment's hostRebuild per environment
Never grepping the build outputYou are trusting the rule, not checking itgrep -rl "$SECRET" .next/static
.env.local in gitSecret in history forever, even after deletionScan history; rotate anything found

Frequently asked questions

Is the Supabase anon key safe to expose? Yes, by design — it is a public identifier whose authority comes from row-level security, not from secrecy. That only holds if your policies are actually restrictive. Ours are read-own-only with no write policy for any signed-in role, which is what makes publishing the key uninteresting. The Supabase-versus-Firebase post covers that posture.

Can I read a server variable inside a Server Component? Yes. Server Components run only on the server, so process.env.SECRET is fine there — provided the value never becomes a prop passed to a Client Component. That boundary is where leaks come from once the prefix rule is understood, and it is why server-only sits on the module rather than on the variable.

What about a runtime-configurable public value? Fetch it. If a value must change without a rebuild, it cannot be a NEXT_PUBLIC_ variable — expose it from a server endpoint or embed it in a server-rendered payload. Trying to make an inlined constant dynamic is the source of most "why is the old URL still there" bug reports.

Do I need a schema validator for environment variables? It helps most on a team, where the failure is "someone deployed without the new variable". A validated schema turns that into one legible error. The trade-off is that a validator which throws at import time can take down more than the feature that needed the variable, so validate loudly in CI and fail narrowly at runtime.

How do I check what a deployed build contains, rather than a local one? Download the deployed chunks and grep those. Anything the browser can fetch, you can fetch — which is the whole point of the exercise, and the reason "it is only in the bundle" was never a hiding place.

Templates that come wired for this

The three below are the security-shaped templates in the catalog — a cyber-security platform, a protection suite and a risk-management site. Each ships the pages where this matters most: sign-in flows, integration pages and anything that talks to a service you hold a key for.

Keep reading

Tutorial11 min read

Next.js Error Boundaries: One File, and a Digest Nothing Read

One error.tsx covering 27 page files, no global-error.tsx, and a digest the boundary declared but discarded — the audit, and both fixes that shipped with this post.

Read more
Tutorial10 min read

Next.js Error Monitoring: 41 console.error Calls, Zero APM

No Sentry, no instrumentation.ts — 41 structured console.error calls, an ALERT-marker convention, and the error-hygiene test that keeps server detail out of the browser.

Read more