Schemets
Back to Blog

Migrating from Firebase to Convex: A Step-by-Step Guide to Real-Time Sync

A comprehensive, practical guide for developers migrating applications from Firebase (Firestore/Realtime Database) to Convex. Learn how to move data, translate listeners to reactive queries, and enjoy end-to-end type safety.

Nicola Violante
FirebaseConvexMigrationBaaSFirestoreRealtime DatabaseReal-time SyncTypeScriptServerlessBackend DevelopmentData Migration

Firebase has long been the default starting point for developers needing a "backend-as-a-service." Its Realtime Database and Firestore introduced millions to the magic of live data syncing. However, as applications scale, many developers hit a wall: the complexity of managing onSnapshot listeners, the "loose" nature of schema-less documents, and the lack of end-to-end type safety can turn a simple app into a maintenance headache.

If you are reading this, you are likely ready for the next evolution in backend development. You want the real-time magic without the manual wiring, and you want the confidence of TypeScript flowing from your database schema directly to your UI components.

Welcome to your migration guide. In this article, we will walk through the practical steps of moving an existing application from Firebase to Convex, translating your data, logic, and mindset to a platform designed for modern full-stack development.

The Paradigm Shift: Mapping Concepts

Before writing code, it is crucial to understand how your existing Firebase knowledge maps to Convex. While both are "serverless" and "real-time," the architectural approach differs significantly.

| Feature | Firebase (Firestore/RTDB) | Convex | | :--- | :--- | :--- | | Data Model | Collections/Nodes (Schema-less) | Tables (Defined by schema.ts) | | Real-time | onSnapshot listeners (Manual) | useQuery hooks (Automatic) | | Logic | Cloud Functions (HTTP/Triggers) | Functions (Queries & Mutations) | | Security | Security Rules (Declarative strings) | TypeScript Logic (ctx.auth, v.rules) | | Typing | Loose / Manual Interfaces | End-to-End (Database to UI) |

In Firebase, you often fight against the database to ensure structure. In Convex, the structure (Schema) helps you write code faster.

Pre-Migration Checklist

Don't start deleting code yet. A successful migration requires preparation.

  1. Backup Everything: Run gcloud firestore export or export your JSON from the Realtime Database console. Do not proceed without a snapshot.
  2. Inventory Your App: List your Collections, key Cloud Functions, and every component using a real-time listener.
  3. Initialize Convex: If you haven't already, set up your new backend:
    npm install convex
    npx convex init
    

Step 1: Translating Data Models to Schema

Firebase encourages a "dump JSON here" approach. Convex encourages you to think about the shape of your data. This is your biggest upgrade: Type Safety.

Let's say you have a Firestore collection named messages. In Firebase, documents might look like this:

{
  "text": "Hello world",
  "userId": "user_123",
  "timestamp": 1672531200000,
  "isRead": false
}

In Convex, you define this structure explicitly. This creates the TypeScript types that will protect you throughout your app.

Create or edit convex/schema.ts:

import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";

export default defineSchema({
  messages: defineTable({
    text: v.string(),
    userId: v.string(),
    timestamp: v.number(), // or v.int64()
    isRead: v.boolean(),
  })
  .index("by_timestamp", ["timestamp"]),
});

Key Difference: In Firestore, you have to remember which fields exist. In Convex, your IDE will autocomplete them.

Step 2: Data Migration

Moving data involves exporting from Firebase and importing into Convex. Since Convex uses mutations to write data, we can create a script to handle the bulk insert.

1. Create a Bulk Import Mutation

In convex/migrations.ts (or similar), create an internalMutation. We use "internal" so this function cannot be called from the public client.

import { internalMutation } from "./_generated/server";
import { v } from "convex/values";

export const importMessages = internalMutation({
  args: {
    data: v.array(
      v.object({
        text: v.string(),
        userId: v.string(),
        timestamp: v.number(),
        isRead: v.boolean(),
      })
    ),
  },
  handler: async (ctx, args) => {
    for (const msg of args.data) {
      await ctx.db.insert("messages", msg);
    }
  },
});

2. Run the Import Script

You can write a simple Node.js script to read your Firebase export and send it to Convex.

// scripts/importData.ts
import { ConvexHttpClient } from "convex/browser";
import { api } from "../convex/_generated/api";
import * as dotenv from "dotenv";
import fs from "fs";

dotenv.config({ path: ".env.local" });

const client = new ConvexHttpClient(process.env.CONVEX_URL!);

// Assume you exported Firestore data to a JSON file
const rawData = fs.readFileSync("./firestore_dump.json", "utf8");
const messages = JSON.parse(rawData);

async function runImport() {
  console.log("Starting migration...");
  // You might want to chunk this for very large datasets
  await client.mutation(api.migrations.importMessages, { data: messages });
  console.log("Migration complete!");
}

runImport();

Step 3: Re-implementing Real-Time Queries

This is where you will feel the most relief. Firebase requires managing subscription lifecycles. Convex handles this declaratively.

The Firebase Way (Before)

In a React component, you typically see this boilerplate:

// Firebase Component
function ChatRoom() {
  const [messages, setMessages] = useState([]);

  useEffect(() => {
    const q = query(collection(db, "messages"), orderBy("timestamp"));
    const unsubscribe = onSnapshot(q, (snapshot) => {
      const msgs = [];
      snapshot.forEach((doc) => msgs.push(doc.data()));
      setMessages(msgs);
    });
    return () => unsubscribe(); // Don't forget cleanup!
  }, []);

  return <ul>{messages.map(m => <li key={m.id}>{m.text}</li>)}</ul>;
}

The Convex Way (After)

First, define the query in convex/messages.ts:

import { query } from "./_generated/server";

export const list = query({
  handler: async (ctx) => {
    return await ctx.db
      .query("messages")
      .withIndex("by_timestamp")
      .order("asc")
      .collect();
  },
});

Then, use the hook in your component:

// Convex Component
import { useQuery } from "convex/react";
import { api } from "../convex/_generated/api";

function ChatRoom() {
  // One line. Fully reactive. Typed.
  const messages = useQuery(api.messages.list);

  if (!messages) return <div>Loading...</div>;

  return <ul>{messages.map(m => <li key={m._id}>{m.text}</li>)}</ul>;
}

The Upgrade: No useEffect, no useState, no manual cleanup. If the data changes on the server, the component re-renders automatically.

Step 4: Migrating Business Logic

In Firebase, business logic often lives in Cloud Functions (triggered by HTTP or database events). These can suffer from "cold starts."

In Convex, Mutations replace Cloud Functions. They run transactionally within the database environment.

Example: Send a Message

Firebase Cloud Function: You might have a Callable Function that checks if a user is logged in, validates the text length, and adds to Firestore.

Convex Mutation: In convex/messages.ts:

import { mutation } from "./_generated/server";
import { v } from "convex/values";

export const send = mutation({
  args: { text: v.string() },
  handler: async (ctx, args) => {
    // 1. Auth Check (See Step 5)
    const identity = await ctx.auth.getUserIdentity();
    if (!identity) {
      throw new Error("Unauthenticated");
    }

    // 2. Validation
    if (args.text.length > 280) {
      throw new Error("Message too long");
    }

    // 3. Database Write
    await ctx.db.insert("messages", {
      text: args.text,
      userId: identity.subject,
      timestamp: Date.now(),
      isRead: false,
    });
  },
});

Because this runs near the database, it is incredibly fast. Plus, args are validated at runtime automatically.

Step 5: Handling Authentication

Firebase Authentication is a separate service from the database, but they are tightly coupled. Convex uses a "Bring Your Own Auth" model, but integrates deeply with providers like Clerk, Auth0, or even custom JWTs.

For a migration, Clerk is often the smoothest transition.

  1. Set up Clerk in your Next.js/React app.
  2. Configure the JWT issuer in your convex.json.
  3. Access user info via ctx.auth.getUserIdentity().

Unlike Firebase Security Rules where you access request.auth.uid, in Convex you access the user identity directly inside your TypeScript functions, allowing for complex permission logic that is easy to test.

Step 6: Translating Security Rules

This is often the scariest part of migration: "How do I ensure my data is safe?"

Firebase Rules:

match /messages/{messageId} {
  allow read: if request.auth != null;
  allow create: if request.auth != null && request.resource.data.text.size() < 280;
}

Convex Logic: Convex doesn't use a separate rules language. Security is part of your function logic.

  • Reads: Your query determines what data is returned. If you add a .filter(), the client cannot bypass it.
  • Writes: Your mutation determines what data is saved.

You simply write TypeScript if statements (as seen in Step 4). This makes your security rules unit-testable and version-controlled alongside your application code.

Conclusion: Enjoying the View

Migrating from Firebase to Convex is more than just swapping databases; it is a quality-of-life upgrade.

By moving, you have gained:

  1. End-to-End Type Safety: No more guessing if a field is a string or a number.
  2. Simplified State: No more useEffect spaghetti code for listeners.
  3. Atomic Consistency: Mutations handle race conditions automatically.

The initial effort of exporting data and rewriting queries pays off immediately in developer velocity. Your backend is no longer a collection of loose JSON and event triggers—it's a robust, type-safe API that feels like an extension of your frontend.

Ready to start? Check out the Convex docs and run that npx convex init command. Your future self will thank you.

Ready to visualize your Convex backend?

Load your Convex folder and explore your schema with interactive tables, relationships, and chat with BackendBOT for intelligent insights.

Related Articles

Convex Cloud vs. Self-Hosted: Choosing Your Backend (Pros, Cons & Use Cases)

Deciding between self-hosting the open-source Convex backend or using the managed Convex Cloud? We break down the pros, cons, and hidden costs to help you choose.

ConvexSelf-HostingCloud ComputingBaaSBackend-as-a-ServiceManaged ServicesOpen SourceDeveloper ExperienceScalabilityRealtimeTypeScriptBackend DevelopmentComparisonSupabaseServerless
Read more →

AI + Convex: Turbocharge Your Full-Stack App Development with ChatGPT

Unlock a new level of productivity by combining the generative power of ChatGPT with the type-safe, real-time backend of Convex. Learn specific prompts and strategies to ship apps faster.

ChatGPTConvexAI DevelopmentFull-StackApp DevelopmentDeveloper ProductivityAI ToolsCode GenerationWeb DevelopmentNext.jsReactBackend DevelopmentRealtime AppsTypeScriptRapid PrototypingServerlessAI Coding Assistant
Read more →

Navigating the BaaS Landscape: A Deep Dive into Convex, Firebase, and Supabase for Modern Web Development

The backend landscape has evolved. We pit the giants against the challengers—Convex vs. Firebase vs. Supabase—to help you decide which platform delivers the speed, scalability, and developer experience your next project demands.

BaaSBackend-as-a-ServiceConvexFirebaseSupabaseBackend ComparisonReal-time BackendType-safe BackendWeb DevelopmentModern BackendDeveloper ExperienceSQLNoSQLPostgreSQLAuthenticationServerless FunctionsTypeScriptJavaScriptNext.jsReactOpen Source BaaSCloud BackendDatabase ComparisonChoosing BaaS
Read more →