Static Export on Next.js 15: What Broke and How We Fixed It
Last week every VietProHub app crashed on load. The error was unhelpful: "This page couldn't load. Reload to try again." No stack trace, no server log — because there is no server.
The symptom
Firebase Hosting served the HTML fine. Assets loaded fine. Then the page went blank. The browser console held the real story:
Missing Firebase environment variables: [NEXT_PUBLIC_FIREBASE_API_KEY, ...]
But the variables were set. The bundle even contained the API key — we could see it inlined in the chunk. So why did the validation code think it was missing?
The bug
The validation looked like this:
const requiredEnvVars = [
"NEXT_PUBLIC_FIREBASE_API_KEY",
"NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN",
// ...
];
const missingVars = requiredEnvVars.filter(
(varName) => !process.env[varName] // ← dynamic access
);
Next.js inlines process.env.NEXT_PUBLIC_FIREBASE_API_KEY at build time — but only for static property access. process.env[varName] with a dynamic key is left as-is, and in the browser process.env is an empty object. Every variable "missing". In production builds the code threw, React unmounted the tree, and the user got the generic reload page.
Meanwhile the actual config object, using static access, had all its values inlined correctly. The crash came purely from the validation step.
The fix
Read each variable statically, then validate:
const requiredEnvVars = [
{ name: "NEXT_PUBLIC_FIREBASE_API_KEY", value: process.env.NEXT_PUBLIC_FIREBASE_API_KEY },
{ name: "NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN", value: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN },
// ...
];
const missingVars = requiredEnvVars.filter(v => !v.value).map(v => v.name);
The rule
For static export, the only reliable pattern is: every process.env.X read must be a literal property access. Lint for it. Grep process.env[ in CI. One dynamic read took down seven apps — the blast radius of a shared package works in both directions.