In short
Migrating from Vite to Next.js renamed every VITE_ env var to NEXT_PUBLIC_. The host still had the old names, so Firebase initialised with undefined keys, getAuth() threw at module scope, and because that module was imported by every page the ErrorBoundary replaced the whole site with 'Something went wrong'.
Gate initialisation behind a config check, create auth lazily in the browser only, and make the data layer fall back to a bundled dataset instead of rethrowing. A missing backend key should cost the features that need a backend, not the site.
The site had been migrated from Vite to Next.js. The build passed. The deploy succeeded. Then every single page rendered this:
Something went wrongNot one broken feature. Seven routes, all of them, replaced by the error boundary's fallback.
What actually happened#
The migration renamed every environment variable from VITE_* to NEXT_PUBLIC_*. The hosting dashboard still had the old names. So the Firebase config compiled to this:
const firebaseConfig = {
apiKey: undefined,
authDomain: undefined,
projectId: undefined,
// ...
};And the module did this with it:
const app = initializeApp(firebaseConfig);
const db = getFirestore(app);
const auth = getAuth(app); // <- throws auth/invalid-api-keygetAuth() threw. The interesting part is not that it threw — with an undefined API key, that is correct behaviour. The interesting part is where it threw.
Why one feature took down seven pages#
That code was at module scope. Not inside a function, not inside a component, not inside an effect. Top level.
Top-level code runs the moment the module is imported — before any component renders, before any hook, before any try/catch inside a component can apply.
And the import graph looked like this:
src/config/firebase.js <- throws here
└── src/utils/db.js <- imports it for getProjects()
└── every portfolio page
└── ErrorBoundary catches, renders the fallbackEvery page imported the data helper. The data helper imported the Firebase config. So a key that only the login form and the admin console actually needed was, structurally, a dependency of the homepage.
The fix#
Three changes, all of them about scoping failure to the thing that failed.
1. Check before initialising.
export const isFirebaseConfigured = Boolean(
firebaseConfig.apiKey && firebaseConfig.projectId && firebaseConfig.appId
);
let app = null;
let db = null;
let auth = null;
if (isFirebaseConfigured) {
app = getApps().length === 0 ? initializeApp(firebaseConfig) : getApp();
db = getFirestore(app);
}No config, no initialisation, no throw. db and auth are null, and callers can ask.
2. Create auth in the browser only.
// Under the App Router this module is also evaluated during server
// rendering, and getAuth() reaches for browser APIs there. Every consumer
// touches `auth` from an effect or a handler, never during render.
if (typeof window !== 'undefined') {
auth = getAuth(app);
}This is a second, separate hazard that the same line was hiding. Under the App Router, a module imported by a client component is still evaluated on the server during SSR. getAuth() expects browser globals. Creating it lazily costs nothing, because nothing reads auth during render anyway.
3. Fall back instead of rethrowing.
export const getProjects = async () => {
if (!isFirebaseConfigured || !db) {
console.warn('Firebase not configured; using bundled portfolio data.');
return seedData;
}
try {
// ...fetch from Firestore
} catch (e) {
console.error('Firestore unavailable, using bundled portfolio data:', e);
return seedData;
}
};This one turned out to matter more than expected. The previous version rethrew, and every portfolio page caught it and rendered "No projects found" — so an offline visitor, a blocked request, or a tightened security rule turned four portfolio pages into empty shells.
The bundled portfolio.json was already in the repository as seed data. Returning it costs nothing and keeps the pages populated. It also keeps them worth crawling, which is a separate problem I hit later and wrote up separately.
What it degrades to now#
| Without Firebase config | Behaviour |
|---|---|
| All 7 public routes | Render normally, full content intact |
| Portfolio grids | Fall back to the bundled dataset |
| Lead form | Reports that it cannot submit |
/admin | Stops at the login screen with an explanation |
Verified the only way worth trusting: delete .env.local, build, serve, and request every route. All 200.
mv .env.local .env.local.bak
npm run build && npm start
# then check every route returns 200, not just the homepageOne more thing that belongs next to the guard, because it is where the confusion goes next:
// NEXT_PUBLIC_* values are inlined at BUILD time, so changing them in the
// hosting dashboard requires a redeploy to take effect.Setting the variables correctly and not redeploying looks exactly like the fix not working.
On exposing the keys at all#
Worth addressing, since NEXT_PUBLIC_ means "readable by anyone who opens the bundle": for the Firebase web config this is fine. Those values are identifiers, not credentials. Access is controlled by Firestore security rules. Hiding the config would protect nothing.
That is emphatically not true of every key. Anything that authorises an action on its own — a third-party API key, a webhook secret — must never live in a NEXT_PUBLIC_ variable. If you need one in the browser, put a route handler in front of it so the secret stays on the server.
What this demonstrates#
The bug was one line in the wrong scope. The lesson is about blast radius.
Before shipping, the useful question is not "what happens if this fails" but "what else fails when this fails" — and the answer is determined by the import graph, not by the feature boundary you had in mind. A login dependency that every page imports is, in practice, a dependency of every page.
The test is cheap: remove the config and see how much of the site disappears. If the answer is more than the feature that needed it, the initialisation is in the wrong place.
- firebase
- nextjs
- app-router
- resilience
- error-boundary
Common questions
Why does a missing environment variable break more than the feature that uses it?
Because of where the failure happens. Code at the top level of a module runs the moment that module is imported, before any component renders and before any error handling inside a component can apply. If that module is in the import graph of every page, a throw there fails every page. The same call inside a function or an effect would only fail its own feature.
Why did setting the environment variables in the hosting dashboard not fix it immediately?
NEXT_PUBLIC_ values are inlined into the bundle at build time, not read at runtime. Setting them in the dashboard changes nothing until the next deploy. Setting them and not redeploying is the most common way this problem appears to persist after it has been fixed.
Is it safe to expose Firebase keys in a NEXT_PUBLIC_ variable?
Yes for the Firebase web config specifically. Those values are identifiers rather than secrets, and access is controlled by Firestore security rules, not by hiding the config. That is not true of every key - anything that authorises an action on its own, such as a third-party API key, must never be in a NEXT_PUBLIC_ variable, because those are readable in the shipped bundle.
Should a data fetch fail loudly or fall back to bundled data?
It depends on whether stale data is worse than no data. For a portfolio listing, bundled data that is slightly out of date is far better than an empty page - an empty page loses both the reader and, because the HTML has nothing in it, the crawler. For anything transactional, such as a form submission, failing loudly is correct, because silently pretending it worked is worse than an error.
Share this
Instagram has no web share link, so this gives you both pieces: copy the caption, save the card, then post it.
Got this problem too?
Bespoke React and Next.js platforms, SaaS interfaces, client portals and interactive tools built for speed.