FirebaseNov 6, 2026 · 6 min read

Configuration Is Part of Your Architecture, Not an Afterthought

My first Firebase project was a tiny app with email login, maybe 200 lines total. I clicked through the console, it gave me a config object with a copy button, and I did the obvious thing: I pasted it straight into a file called firebase.js, committed, and pushed to GitHub. It worked first try, which is exactly why I did not think about it again for weeks.

Then I opened the repo on a different laptop to show a friend, and there it was, right in the file tree: my project id, my api key, all seven values, sitting in a public repo. Nothing bad happened, and that is the point. The keys were not the problem. The problem was that I had no config strategy at all, no line between what belongs in the frontend and what needs real protection. The fix took about 20 minutes. The lesson stuck a lot longer.

The config the console gives you

The snippet looks harmless because it looks like plain config. Seven keys, all strings, nothing that screams password.

firebase.js (the way I did it first)TypeScript
import { initializeApp } from 'firebase/app';

const firebaseConfig = {
  apiKey: 'AIzaSyB4x...',
  authDomain: 'my-app.firebaseapp.com',
  projectId: 'my-app',
  storageBucket: 'my-app.appspot.com',
  messagingSenderId: '1234567890',
  appId: '1:1234567890:web:abc123',
  measurementId: 'G-ABC123XYZ',
};

export const app = initializeApp(firebaseConfig);

Here is the part that surprised me: the apiKey is not a secret. It looks like one, it is shaped like one, and every instinct says to hide it, but Firebase uses it as a public identifier for your project, not as a password. What actually protects your data is your security rules, the rules that say who can read and write what, plus IAM and restricted API keys for the parts that need real limits. The keys that are real secrets live somewhere else entirely: service account keys, the ones you download as JSON for a server. Those go in env variables too, and they stay out of the repo completely.

Anyways, even though the web config is safe to expose, it still belongs in env variables. Three reasons, and the third is the one that actually got me. One, a config in code is a config you cannot change per environment: dev, staging, and prod all point at the same project. Two, a committed config is a committed habit, and the day you paste a real secret the same way, git remembers it forever. Three, moving it takes 20 minutes and you only have to do it once.

The .env file

With Vite, env variables live in a .env file at the project root, and the rule is simple: a variable only reaches your code if its name starts with VITE_. Anything without the prefix stays on the server side of the build, which is the part that trips people up first. You read them with import.meta.env.

.envTypeScript
VITE_FIREBASE_API_KEY=AIzaSyB4x...
VITE_FIREBASE_AUTH_DOMAIN=my-app.firebaseapp.com
VITE_FIREBASE_PROJECT_ID=my-app
VITE_FIREBASE_STORAGE_BUCKET=my-app.appspot.com
VITE_FIREBASE_MESSAGING_SENDER_ID=1234567890
VITE_FIREBASE_APP_ID=1:1234567890:web:abc123
# Optional: only set this if Analytics is enabled
# VITE_FIREBASE_MEASUREMENT_ID=G-ABC123XYZ

That last line is the one the console snippet hides from you. measurementId only exists if you enabled Google Analytics for the project. Plenty of projects do not have it, and the config works fine without it. The topic of this post is the full setup, and the full setup includes the ones you leave unset, so here it is: an unset variable is not an error, it is just a blank. The code has to know that.

firebase.js reads it

The config file itself barely changes. Same object, same keys, but every value comes from the environment now, and the file is safe to commit.

firebase.jsTypeScript
import { initializeApp } from 'firebase/app';
import { getAuth } from 'firebase/auth';
import { getFirestore } from 'firebase/firestore';

const firebaseConfig = {
  apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
  authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
  projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
  storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET,
  messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID,
  appId: import.meta.env.VITE_FIREBASE_APP_ID,
  measurementId: import.meta.env.VITE_FIREBASE_MEASUREMENT_ID,
};

export const app = initializeApp(firebaseConfig);
export const auth = getAuth(app);
export const db = getFirestore(app);

The unset ones

When a variable is missing from the .env file, import.meta.env.VITE_FIREBASE_MEASUREMENT_ID is just undefined. Firebase is fine with that for measurementId. initializeApp does not touch Analytics at all, that only starts if you call getAnalytics, which is the part that needs the measurementId. It is not fine with an undefined apiKey, and the error Auth throws says auth/invalid-api-key, which tells you nothing about the actual problem: a missing line in a file you probably did not know existed.

So I put a check at the top of firebase.js, before initializeApp runs. It lists exactly which variables are missing, in plain words, instead of letting Firebase guess.

firebase.js (validation)TypeScript
const required = [
  'VITE_FIREBASE_API_KEY',
  'VITE_FIREBASE_AUTH_DOMAIN',
  'VITE_FIREBASE_PROJECT_ID',
  'VITE_FIREBASE_STORAGE_BUCKET',
  'VITE_FIREBASE_MESSAGING_SENDER_ID',
  'VITE_FIREBASE_APP_ID',
];

const missing = required.filter((key) => !import.meta.env[key]);

if (missing.length > 0) {
  throw new Error('Missing Firebase env variables: ' + missing.join(', '));
}

// measurementId is optional, so it stays out of this list on purpose
Try it: a sample config, checked the same way

A sample config, checked the way the validation helper checks it. Toggle the apiKey off and watch the error change.

  • ✓VITE_FIREBASE_API_KEY set: Required. Without it, Auth throws.
  • ✓VITE_FIREBASE_AUTH_DOMAIN set: Required for Auth.
  • ✓VITE_FIREBASE_PROJECT_ID set: Required. This is the project.
  • ✓VITE_FIREBASE_STORAGE_BUCKET set: Required for Storage.
  • ✓VITE_FIREBASE_MESSAGING_SENDER_ID set: Required for Cloud Messaging.
  • ✓VITE_FIREBASE_APP_ID set: Required. Identifies the web app.
  • ○VITE_FIREBASE_MEASUREMENT_ID unset: Optional. Only needed for Analytics.

Missing Firebase env variables: VITE_FIREBASE_MEASUREMENT_ID

The point of the demo is the last row. measurementId is unset and nothing breaks, because it is optional and the check knows it. Unset the apiKey with the button and the same check turns into an error that names the missing variable. That is the whole difference: a clear message at startup beats a cryptic one at runtime.

Keep it out of git

The .env file holds the values, so it stays out of the repo. One line in .gitignore does it. What does go in the repo is a .env.example: same keys, empty values, so the next person (usually future me) knows exactly what to fill in.

.env.exampleTypeScript
VITE_FIREBASE_API_KEY=
VITE_FIREBASE_AUTH_DOMAIN=
VITE_FIREBASE_PROJECT_ID=
VITE_FIREBASE_STORAGE_BUCKET=
VITE_FIREBASE_MESSAGING_SENDER_ID=
VITE_FIREBASE_APP_ID=
# Optional, Analytics only
VITE_FIREBASE_MEASUREMENT_ID=

Two more files worth knowing about. .env.local overrides .env on your machine only, handy when you want a second Firebase project for local experiments without touching the shared file. And .env.production is a trap for most small projects: it looks like the right place for prod values, but those values get committed with it, so put prod values in your host instead.

Production reads a different file

On Vercel, the variables live in the project settings under Environment Variables, not in any file. Vite inlines them at build time, which means changing one needs a redeploy, editing a local .env does nothing for production. I learned that one by staring at a deployed app for ten minutes wondering why my new value was not there. It was not there because the deployed build was made before I typed it.

One more detail worth the line: the values you set in Vercel apply per environment, so production can point at the real project while preview deployments point at a throwaway one. That is the payoff for the whole setup. Same code, different config, nothing hardcoded.