drums
Sign up

A Next.js env bug that passes every server test and breaks every browser

2026-08-01 · 4 min read · Drums Labs

We shipped a sign-in page that rendered NEXT_PUBLIC_SUPABASE_URL is not set to real people while every test we had passed and curl returned a healthy 200.

The code

function required(name: string): string {
  const value = process.env[name];          // ← the bug
  if (!value) throw new Error(`${name} is not set`);
  return value;
}

export function publicEnv() {
  return {
    url: required("NEXT_PUBLIC_SUPABASE_URL"),
    key: required("NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY"),
  };
}

That is a perfectly ordinary helper. It is also unusable in a browser bundle, and nothing warns you.

NEXT_PUBLIC_ is a text substitution, not a lookup

Next does not ship process.env to the browser. At build time it scans for the literal text process.env.NEXT_PUBLIC_WHATEVER and replaces it with the value. It is closer to sed than to a runtime read.

process.env[name] is an indexed access. There is no literal member expression for the bundler to match, so nothing is substituted. In the browser process.env is {}, the lookup is undefined, and the helper throws.

Why every test passed

On the server there is a real process.env. So the indexed read works perfectly in:

Our whole suite lived there. Thirty-nine end-to-end assertions against a live database, all green, against an application whose sign-in page could not function. The failure is confined to code that runs in a browser, and we had no test that ran in one.

The fix, and the guard that matters more

Read them as literals at module scope:

const PUBLIC_URL = process.env.NEXT_PUBLIC_SUPABASE_URL;
const PUBLIC_KEY = process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY;

Then assert the thing that actually matters — that the value is in the JavaScript the browser downloads:

grep -rqF "$NEXT_PUBLIC_SUPABASE_URL" .next/static \
  || { echo "value never reached the browser bundle"; exit 1; }

Ours runs on every build. It fails against the old build and passes against the new one, which is the only way to know a check works.

Check the inverse too

A tempting way to "fix" a missing public value is to make a secret public. So the same script asserts the opposite for the service key:

grep -rqF "$SUPABASE_SECRET_KEY" .next/static \
  && { echo "SECRET KEY IS IN THE BROWSER BUNDLE"; exit 1; }

That one would pass every other check in the repository. It is worth having before somebody needs it.


Drums is self-healing software: it links a production failure to the deploy that caused it, reproduces it, repairs it with your own coding agent, and proves the fix. Read more or browse the docs.

© 2026 Drums Labs, Inc. · drums.sh · Docs · Privacy