Guide · Firebase

Migrate Firebase JS SDK v8 to the modular API (v9+)

Version 9 of the Firebase JavaScript SDK introduced the modular API: instead of chaining methods off a global firebase object, you import functions such as getFirestore and getDoc and pass the service in. The compat libraries let you get there one product at a time.

Firebase 9.0.0 was published to npm on August 25, 2021, and the current major is 13.0.0. Every release since 9 ships two API surfaces in the same firebase package: the modular API, built for tree shaking, and the namespaced compat libraries, which are API compatible with v8 code. Firebase says the namespaced API will not get new feature development, that compat is a temporary bridge to be removed in a future major, and that tree shaking with the modular SDK can make some apps around 80% smaller, provided you use a module bundler. This guide follows Firebase's upgrade guide and its Cloud Firestore and Authentication docs, linked at the end.

What changed

  • Imports name functions instead of loading side-effect modules: import { getFirestore, doc, getDoc } from "firebase/firestore" replaces import "firebase/firestore".
  • firebase.initializeApp(config) becomes initializeApp(config) from firebase/app, and services come from getAuth() and getFirestore().
  • Methods become free functions that take the service or reference first: auth.onAuthStateChanged(cb) becomes onAuthStateChanged(auth, cb).
  • Firestore references are built with doc(db, "cities", "SF") and collection(db, "cities"), then read with getDoc, getDocs and onSnapshot.
  • Writes use setDoc, addDoc and updateDoc, and firebase.firestore.FieldValue.serverTimestamp() becomes serverTimestamp().
  • DocumentSnapshot.exists is a method now: snapshot.exists().
  • getRedirectResult returns null when no redirect happened, instead of a credential with a null user.
  • The firebase/compat/* imports run v8-style code on the new package while you migrate.

Migration steps

  1. Install the new SDK and switch imports to compat

    npm install firebase@13.0.0

    Before

    import firebase from "firebase/app";
    import "firebase/auth";
    import "firebase/firestore";

    After

    import firebase from "firebase/compat/app";
    import "firebase/compat/auth";
    import "firebase/compat/firestore";

    Compat code is identical to v8 code; only the imports change. This is a good checkpoint to ship on its own: the app runs on the new package, and you can refactor the rest in small pieces. Do not expect a smaller bundle yet. Firebase says compat libraries have little to no size or performance advantage over the namespaced ones.

  2. Refactor one product at a time, starting with Authentication

    Before

    const auth = firebase.auth();
    auth.onAuthStateChanged((user) => {
      // check for user status
    });
    await auth.signInWithEmailAndPassword(email, password);

    After

    import { getAuth, onAuthStateChanged, signInWithEmailAndPassword } from "firebase/auth";
    
    const auth = getAuth();
    onAuthStateChanged(auth, (user) => {
      // check for user status
    });
    await signInWithEmailAndPassword(auth, email, password);

    getAuth() with no argument uses the default app, and getAuth(app) takes an explicit one. Because compat firebase.initializeApp() sets up state for both APIs, modular code like this works while initialization is still in compat style. Firebase's suggested order is to finish one product, optionally remove that product's compat import to bank its size savings, then move to the next.

    Before

    const result = await auth.getRedirectResult();
    if (result.user === null && result.credential === null) {
      return null;
    }

    After

    const result = await getRedirectResult(auth);
    if (result === null || provider.credentialFromResult(result) === null) {
      return null;
    }
    getRedirectResult returns null when there was no redirect.
  3. Rewrite Firestore reads and queries

    Before

    const db = firebase.firestore();
    const snap = await db.collection("cities").doc("SF").get();
    if (snap.exists) {
      console.log(snap.data());
    }
    
    const results = await db.collection("cities").where("capital", "==", true).get();

    After

    import { getFirestore, doc, getDoc, collection, query, where, getDocs } from "firebase/firestore";
    
    const db = getFirestore();
    const snap = await getDoc(doc(db, "cities", "SF"));
    if (snap.exists()) {
      console.log(snap.data());
    }
    
    const q = query(collection(db, "cities"), where("capital", "==", true));
    const results = await getDocs(q);

    Queries are built from free functions rather than chains: query() takes a collection reference followed by constraints such as where. Query snapshots still expose forEach, id and data() the way they did before.

  4. Rewrite writes and field values

    Before

    await db.collection("cities").doc("LA").set({ name: "Los Angeles", state: "CA" });
    await db.collection("cities").doc("BJ").set({ capital: true }, { merge: true });
    const ref = await db.collection("cities").add({ name: "Tokyo", country: "Japan" });
    await db.collection("objects").doc("some-id").update({
      timestamp: firebase.firestore.FieldValue.serverTimestamp(),
    });

    After

    import { doc, setDoc, addDoc, updateDoc, collection, serverTimestamp } from "firebase/firestore";
    
    await setDoc(doc(db, "cities", "LA"), { name: "Los Angeles", state: "CA" });
    await setDoc(doc(db, "cities", "BJ"), { capital: true }, { merge: true });
    const ref = await addDoc(collection(db, "cities"), { name: "Tokyo", country: "Japan" });
    await updateDoc(doc(db, "objects", "some-id"), {
      timestamp: serverTimestamp(),
    });

    set, add and update map one to one onto setDoc, addDoc and updateDoc, and the { merge: true } option keeps its shape. Values that hung off firebase.firestore.FieldValue are imported directly from firebase/firestore.

  5. Move listeners to onSnapshot

    Before

    const unsubscribe = db.collection("cities").doc("SF")
      .onSnapshot((doc) => {
        console.log("Current data: ", doc.data());
      });

    After

    import { doc, onSnapshot } from "firebase/firestore";
    
    const unsubscribe = onSnapshot(doc(db, "cities", "SF"), (doc) => {
      console.log("Current data: ", doc.data());
    });

    onSnapshot still returns a function that detaches the listener, so cleanup code such as unsubscribe() in a React effect does not change.

  6. Mix styles while you migrate

    Compat and modular code can live in the same file and even work on the same objects, as long as the compat packages are imported. A reference created the old way can be passed to a modular function:

    import firebase from "firebase/compat/app";
    import "firebase/compat/firestore";
    import { getDoc } from "firebase/firestore";
    
    const docRef = firebase.firestore().doc("cities/SF");
    const snap = await getDoc(docRef);

    That lets you migrate a large Firestore layer function by function instead of in one change. The app compiles throughout, but none of the size benefit arrives until the compat imports are gone.

  7. Update initialization last, then remove compat

    Before

    import firebase from "firebase/compat/app";
    
    firebase.initializeApp({ /* config */ });

    After

    import { initializeApp } from "firebase/app";
    
    const firebaseApp = initializeApp({ /* config */ });

    Order matters here. Compat firebase.initializeApp() initializes global state for both APIs, but modular initializeApp() initializes only the modular side, so switching it early breaks every compat call that is left. Once nothing uses compat, delete every firebase/compat/* import; Firebase's end state has no references to the firebase.* namespace at all. Apps loading Firebase from <script> tags on gstatic.com can use the compat scripts as a stepping stone, but Firebase does not recommend the window.firebase approach long term because it prevents unused code from being removed.

How to find every affected call

Namespaced code always starts from the firebase object or a service instance, so ast-grep finds it reliably, including chains split across lines:

sg run --pattern 'firebase.initializeApp($$$ARGS)' --lang ts
sg run --pattern 'firebase.firestore()' --lang ts
sg run --pattern 'firebase.auth()' --lang ts
sg run --pattern '$DB.collection($$$ARGS)' --lang ts
sg run --pattern '$REF.onSnapshot($$$ARGS)' --lang ts
sg run --pattern 'firebase.firestore.FieldValue.$METHOD($$$ARGS)' --lang ts
sg run --pattern '$SNAP.exists' --lang ts

Repeat with --lang tsx and --lang js. $DB.collection and $SNAP.exists match other libraries and already-migrated exists() calls too, so review each hit. For imports, search your source for firebase/compat/ and for side-effect imports such as import "firebase/firestore"; ast-grep matches string literals exactly, quote style included.

Testing the change

  • Run tsc --noEmit with strict null checks. TypeScript reports a condition like if (snap.exists) as always true once exists is a function, which catches the one change that fails silently.
  • Fix mocks. A jest.mock of firebase/app that returns { firestore: () => fakeDb } stops intercepting once code imports functions from firebase/firestore; mock those functions or run tests against Firebase's local emulators.
  • Test the sign-in redirect path with and without a pending redirect, since getRedirectResult now returns null in the second case.
  • After removing compat, check your bundle analyzer output for leftover compat chunks. One forgotten import keeps the whole namespaced layer in the bundle.

Automating it

We found no official codemod for this migration. Firebase's upgrade guide describes a manual, product-by-product refactor, with the compat libraries keeping the app running between steps.

Versyka finds the namespaced call sites deterministically with ast-grep patterns like the ones above, rewrites them along with the code that depends on them (snapshot checks, listeners, mocks), then runs your repository's install, typecheck, build and tests in a disposable sandbox. A pull request is opened only when those pass.

Was this guide helpful?

Check your own repository

Paste your package.json, requirements.txt or pyproject.toml into the free upgrade check to see which API SDKs are behind, with no account. Or let Versyka run this migration on one repository and open a pull request that has already passed your tests.

Sources