A Next.js env bug that passes every server test and breaks every browser
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:
- route handlers
- server components
- middleware
- anything
curlcan reach
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.