How I Migrated 1000+ Users from Firebase to Supabase


When beginning my journey as a Flutter developer, I thought Firebase would be the best backend solution for Flutter. Given that both are made by Google, I thought this would be the backend that would be the most stable and flexible. I was wrong.

The lack of full native Dart support, slow build times, sketchy workarounds, and no desktop support led me searching for another solution. After many hours of research, I was faced with the choice between two frameworks: Appwrite and Supabase. Both were great frameworks and both fit my use case perfectly. But, I decided to move forward with Supabase because of their philosophy of "not reinventing the wheel".

So now I was tasked with the problem of moving 1000+ active users from one platform to another. This was no easy feat, and I wanted to share my experience in case some one else wanted to perform a similar migration.

Migration Criteria

I wanted a seamless migration. At most, the user would only need to reauthenticate into the app and they would see everything they'd expect to see. In addition, I wanted the migration to be backwards compatible with the previous app. Users aren't going to update every app concurrently, so it'd make sense for the supabase version to be backwards compatible with the firebase version of the app.

In order to make the migration seamless and be backwards compatible, three components needed to be migrated:

  • Subscription tier: Subscription tier must be constantly up-to-date between the firebase and supabase version of the app.
  • Authentication & User data (username, password, etc): Authentication / user data only needs to be synced once immediately after the account migration
  • Notes data: Notes must be bi-directionally synced in realtime

Each critera had its own different set of problems. In the following sections, I'll be walking through the problems I faced with each criteria, and the solution I eventually settled upon.

Stripe migration

Backend Stripe Migration

Stripe was the most difficult and finicky aspect of the migration. Within firebase, I used the stripe firebase extension to manage all the stripe components. It took care of a lot of the implementation work so I had no idea how to approach this when I first started. I bounced between different ideas like bi-directional sync of the subscription tier within firebase, but eventually I settled upon using Stripe as my single source of truth. Although it'd be more involved, I knew it'd make fully cutting Firebase out of Fleeting Notes easier down the road. Here are the migration steps I settled upon:

  1. User signs in and behind the scenes account migration occurs
  2. After account migration, a supabase database functionhandle_new_usersA function that is run using a postgres trigger right when a new user is created Example Video adds a row to the stripe table with the supabase id filled in.
  3. Once the row is added, a database webhook calls a firebase functionget_stripe_id_from_firebaseA supabase function that gets the stripe id from firebase and updates the table within supabase. 1. Make a request to get the stripe id from firebase 1. Update the table with the stripe_customer_id requested from firebase // Step 1 let res = await fetch(uidToStripeIdUrl, { method: "POST", headers: { "content-type": "application/json", }, body: JSON.stringify({ uid: firebaseUid }) }); // Step 2 const { data, error } = await supabase .from("table") .update({ that grabs the relevant stripe_customer_id from firebase and populates it into supabase.
  4. Once the stripe_customer_id is populated into the table, another database webhook is triggered to pull the subscrition tier from stripeupdate_subscription_tierA supabase function that is triggered by a database webhook when the stripe_customer_id is created or updated. 1. Check if it's a valid stripe_customer_id by querying subscriptions 1. If subscription is "active" or "trialing", then retrieve the subscription_tier from the metadata of the subscription plan. 1. Otherwise, set the subscription_tier to "free" 1. Update the row of the stripe_customer_id with the new subscription_tier let subscription_tier = 'free'; // Step 1 const sub = await stri.
  5. Additionally, stripe webhooksstripe_webhooksA supabase function that gets realtime updates from stripe then updates the subscription_tier of users. Steps: 1. Verify stripe signing signature 1. Query stripe API to avoid handling any forged event 1. Update supabase the subscription tier of the user based on the stripe_customer_id Example Code Video Walkthrough are set up to update the subscription tier of customers whenever they are updated (e.g. trialing period ends).

Payments Page

Another component I needed to upgrade was the payments page. For the current payments page, I closely modelled it to this sample payments page. The only problem with this payments page is that supabase isn't notified of any new customers after the migration.

Meaning, if someone were to create a new subscription, only the firebase version of the app would know that they are a paying user and the supabase version would not. Hence, I needed to connect these in realtime and the way to do this was to utilize firebase trigger functions. Here's how I did it:

  1. A user creates a subscription in the payments page
  2. The stripe firebase extension creates a new customer in the customer collection in firebase
  3. A firebase functionupdate_stripe_customer_idA firebase function that is triggered whenever a new stripe customer is added from the firebase stripe extension. The function then updates the supabase table with the correct stripe_customer_id. Steps: 1. Firebase function is triggered 1. The supabase_user_id is queried using the firebase_user_id 1. If a supabase_user_id exists, then upserts the supabase_user_id along with the stripe_customer_id into a table. exports.update_stripe_customer_id = functions.firestore.document('customers/{uid}') is triggered to update the supabase tables with the correct stripe_customer_id
  4. If the stripe_customer_id is updated, this triggers a supabase functionupdate_subscription_tierA supabase function that is triggered by a database webhook when the stripe_customer_id is created or updated. 1. Check if it's a valid stripe_customer_id by querying subscriptions 1. If subscription is "active" or "trialing", then retrieve the subscription_tier from the metadata of the subscription plan. 1. Otherwise, set the subscription_tier to "free" 1. Update the row of the stripe_customer_id with the new subscription_tier let subscription_tier = 'free'; // Step 1 const sub = await stri that updates the subscription tier

With all this functionality, we now have a table that's constantly up to date with the subscription tier of the user. This table also stores the stripe_customer_id. Which can be used to create checkout sessionscreate_stripe_checkoutA supabase function that creates and returns a url to a stripe checkout session. Steps: 1. Get the supabase_user_id from the request 1. Use the supabase_user_id to get the stripe_customer_id from a table 1. Create a stripe checkout session and return the url of the stripe checkout session Example Code Video Walkthrough or redirect to the stripe customer portalstripe_customer_portalA supabase function that creates a customer portal and returns the url for a particular user. 1. Get the supabase_user_id from the request 1. Using the supabase_user_id query the table to get the stripe_customer_id 1. Create a stripe customer portal and return the url to access the portal Example Code Video Walkthrough. For our particular use case, we redirect the user to a checkout session if they are a free user (after sign in), otherwise redirect them to the stripe customer portal.

Resources that helped me

A big shout out to the supabase team, supabase happy hour series, egghead stripe course and the happy-days repo for helping with this component of the migration. I definitely would've spent way more time figuring out what I needed to do without these helpful courses / videos.

Notes migration

The idea of the two-way sync came from a github comment by the co-founder of supabase. Here's how I made it work on the supabase end:

  1. A user saves a note with the supabase version of the app
  2. A database webhook sends the updated note data to a firebase functionsupabase_notes_to_firebaseA firebase function that is called from firebase with the updated note data through a database webhook. This function updates the note within firebase from supabase. 1. With the updated note data from the supabase, a query is made to supabase for the same note within firebase 1. If the note from supabase was modified_at later than the note from firebase, then we skip the following steps. 1. Otherwise, upsert the updated note into supabase from firebase. exports.supabase_notes_to_firebase = fun that saves the note to firebase.

A very similar thing happens on the firebase end:

  1. A user saves a note with the firebase version of the app
  2. A firebase trigger functionfirebase_notes_to_supabaseA firebase function that is triggered whenever a note is created or modified. This function then updates the note within supabase. 1. With the updated note data from the firebase trigger, a query is made to firebase for the same note within supabase 1. If the note from firebase was modified_at later than the note from supabase or if it is not a new note, then we skip the following steps. 1. Otherwise, upsert the updated note into firebase from supabase. exports.firebase_notes_to_supabase = fun updates supabase with the updated note

With this the notes are bi-directionally synced, but there was an issue where these functions keep triggering each other indefinitely. I avoided this was by using the modified_at field within the note. The update would not proceed if the note in the database was modified_at later than the note in question. Now the databases won't be calling each other back and forth indefinitely.

After I got the sync set up, I still needed to migrate all existing notes from firebase to supabase. To do this, I ran a script to transfer all notes from firebase to supabasemigrate_firebase_notesA javascript function used to migrate all existing notes from firebase to supabase. const migrate_firebase_notes = async (res, batch_size = 1000) => { const supabase = createClient(process.env.SUPABASE_URL, process.env.SUPABASE_SERVICE_ROLE_KEY); let count = 0; let lastDoc; while (count === 0 || lastDoc) { let ref; if (lastDoc) { console.log(Loaded ${batch_size} notes to supabase, total: ${count}, last note_id: ${lastDoc.id}) ref = db .collection('notes') .

Account Migration

Account migration is done within the supabase version on sign in. Here's how it works:

  1. User clicks "sign in" for the first time on the supabase version of the app
  2. Using the credentials entered, two concurrent sign in attempts are made to the firebase and supabase.
  3. Only if supabase fails the login and firebase succeeds, we continue to the next steps.
    1. Otherwise we proceed the login (if supabase attempt successful) or fail the login (if supabase attempt unsuccessful).
  4. Attempt to register for supabase. On registration a series of backend functions are called to migrate the user data:
    1. On user creation, database functionhandle_new_usersA function that is run using a postgres trigger right when a new user is created Example Video is called to insert rows to into other tables.
    2. Then, this triggers two database webhooks that get the stripe_customer_idget_stripe_id_from_firebaseA supabase function that gets the stripe id from firebase and updates the table within supabase. 1. Make a request to get the stripe id from firebase 1. Update the table with the stripe_customer_id requested from firebase // Step 1 let res = await fetch(uidToStripeIdUrl, { method: "POST", headers: { "content-type": "application/json", }, body: JSON.stringify({ uid: firebaseUid }) }); // Step 2 const { data, error } = await supabase .from("table") .update({ and encryption keytransfer_encryption_keyA firebase function that transfers the hashed encryption key for end-to-end encryption from firebase to supabase. 1. The function takes supabase_user_id as an input 1. The supabase_user is queried based on the supabase uid 1. The firebase_user_id is extracted from the supabase_user 1. encryption_key is queried from firebase using the firebase_user_id 1. The supabase_user_id and the encryption_key are upserted into supabase exports.transfer_encryption_key = functions.https.onRequest(async (req, from firebase.
    3. If the stripe_customer_id exists, then another database webhook is triggered to get the subscription_tierupdate_subscription_tierA supabase function that is triggered by a database webhook when the stripe_customer_id is created or updated. 1. Check if it's a valid stripe_customer_id by querying subscriptions 1. If subscription is "active" or "trialing", then retrieve the subscription_tier from the metadata of the subscription plan. 1. Otherwise, set the subscription_tier to "free" 1. Update the row of the stripe_customer_id with the new subscription_tier let subscription_tier = 'free'; // Step 1 const sub = await stri
  5. After registration is complete, another supabase login attempt is made to login the user.

A single bad, but "acceptable" scenario

  • If a user changes their password after the account migration, then the password change won't be reflected in the other version of the app.

Other considerations I had

  • Firebase migration guide: Migration guide from supabase would've been nice if I didn't want a seamless migration. Going about this way would make the supabase version fully incompatible with the firebase version. In addition, the multi-platform nature would mean that users could be using different versions of the app. This would cause unexpected behaviour and make for a bad user experience. That being said, my script to migrate notesmigrate_firebase_notesA javascript function used to migrate all existing notes from firebase to supabase. const migrate_firebase_notes = async (res, batch_size = 1000) => { const supabase = createClient(process.env.SUPABASE_URL, process.env.SUPABASE_SERVICE_ROLE_KEY); let count = 0; let lastDoc; while (count === 0 || lastDoc) { let ref; if (lastDoc) { console.log(Loaded ${batch_size} notes to supabase, total: ${count}, last note_id: ${lastDoc.id}) ref = db .collection('notes') was inspired by code in this repo.
  • Stripe-Supabase Sync Engine: syncing the stripe database to supabase would've been nice but also would've been a lot of unnessary extra work (I'll just use stripe webhooks instead). Also, I needed to spin up something to host that and I wasn't willing to do that.
  • Two-way sync of account data: Originally the plan was to have two-way sync of the account data. Once I set this up, both versions would automatically be 100% compatible with each other (with two-way note sync). But, I soon realized that it was a lot more work than anticipated because of several reasons: there aren't firebase hooks for account creation or account data modification, I needed to deal with encrypting and decrypting hashed passwords, and I couldn't use the SDK to do this. Instead, I settled on migrating the user on sign in, so I do have access to the email & password while performing the migration.
  • Save notes to supabase & firebase within the app: Another consideration was to save notes to two servers (supabase & firebase). The problem with this was that I wanted to completely remove firebase dependencies from within my app and doing this would delay that. Also, saving might not be reliable as connectivity issues can cause one note to be saved on one server but not the other.

Was it worth it?

Hopefully.

This entire migration process probably took 3 weeks of my time to plan, execute and fix (Yes, there were some bugs upon initial release). During this time, I could've been marketing or developing new features. It was a lot more work than anticipated and it probably would've been more if I saved it for later. But with this migration, I feel more at ease about the future of Fleeting Notes and I'm excited for what to come in the following months.

All backend functions used for migration:

Firebase functions

  • notes/update_stripe_customer_idupdate_stripe_customer_idA firebase function that is triggered whenever a new stripe customer is added from the firebase stripe extension. The function then updates the supabase table with the correct stripe_customer_id. Steps: 1. Firebase function is triggered 1. The supabase_user_id is queried using the firebase_user_id 1. If a supabase_user_id exists, then upserts the supabase_user_id along with the stripe_customer_id into a table. exports.update_stripe_customer_id = functions.firestore.document('customers/{uid}')
  • notes/get_stripe_id_from_firebase_uidget_stripe_id_from_firebase_uidA firebase function that gets the stripe customer id given a firebase user id. 1. Makes a query to a table given a firebase_user_id 1. Returns the stripe_customer_id found the document within the table exports.get_stripe_id_from_firebase_uid = functions.https.onRequest(async (req, res) => { // Step 1 const uid = req.body.uid; const ref = db.collection(customers).doc(uid); const doc = await ref.get(); // Step 2 const stripeId = doc.data()?.stripeId; return res.status(200).json({
  • notes/transfer_encryption_keytransfer_encryption_keyA firebase function that transfers the hashed encryption key for end-to-end encryption from firebase to supabase. 1. The function takes supabase_user_id as an input 1. The supabase_user is queried based on the supabase uid 1. The firebase_user_id is extracted from the supabase_user 1. encryption_key is queried from firebase using the firebase_user_id 1. The supabase_user_id and the encryption_key are upserted into supabase exports.transfer_encryption_key = functions.https.onRequest(async (req,
  • notes/firebase_notes_to_supabasefirebase_notes_to_supabaseA firebase function that is triggered whenever a note is created or modified. This function then updates the note within supabase. 1. With the updated note data from the firebase trigger, a query is made to firebase for the same note within supabase 1. If the note from firebase was modified_at later than the note from supabase or if it is not a new note, then we skip the following steps. 1. Otherwise, upsert the updated note into firebase from supabase. exports.firebase_notes_to_supabase = fun
  • notes/supabase_notes_to_firebasesupabase_notes_to_firebaseA firebase function that is called from firebase with the updated note data through a database webhook. This function updates the note within firebase from supabase. 1. With the updated note data from the supabase, a query is made to supabase for the same note within firebase 1. If the note from supabase was modified_at later than the note from firebase, then we skip the following steps. 1. Otherwise, upsert the updated note into supabase from firebase. exports.supabase_notes_to_firebase = fun
  • notes/migrate_firebase_notesmigrate_firebase_notesA javascript function used to migrate all existing notes from firebase to supabase. const migrate_firebase_notes = async (res, batch_size = 1000) => { const supabase = createClient(process.env.SUPABASE_URL, process.env.SUPABASE_SERVICE_ROLE_KEY); let count = 0; let lastDoc; while (count === 0 || lastDoc) { let ref; if (lastDoc) { console.log(Loaded ${batch_size} notes to supabase, total: ${count}, last note_id: ${lastDoc.id}) ref = db .collection('notes')

Supabase functions

  • notes/create_stripe_checkoutcreate_stripe_checkoutA supabase function that creates and returns a url to a stripe checkout session. Steps: 1. Get the supabase_user_id from the request 1. Use the supabase_user_id to get the stripe_customer_id from a table 1. Create a stripe checkout session and return the url of the stripe checkout session Example Code Video Walkthrough
  • notes/create_stripe_customercreate_stripe_customerA supabase function that creates a stripe customer and returns the stripe_customer_id. Steps: 1. Get the supabase_user_id from the request 1. Check if stripe_customer_id already exists in the table. If it does, skip the following steps 1. Create stripe customer and store the stripe_customer_id 1. Update the table with the stripe_customer_id and return it Example Code Video Walkthrough
  • notes/get_stripe_id_from_firebaseget_stripe_id_from_firebaseA supabase function that gets the stripe id from firebase and updates the table within supabase. 1. Make a request to get the stripe id from firebase 1. Update the table with the stripe_customer_id requested from firebase // Step 1 let res = await fetch(uidToStripeIdUrl, { method: "POST", headers: { "content-type": "application/json", }, body: JSON.stringify({ uid: firebaseUid }) }); // Step 2 const { data, error } = await supabase .from("table") .update({
  • notes/stripe_customer_portalstripe_customer_portalA supabase function that creates a customer portal and returns the url for a particular user. 1. Get the supabase_user_id from the request 1. Using the supabase_user_id query the table to get the stripe_customer_id 1. Create a stripe customer portal and return the url to access the portal Example Code Video Walkthrough
  • notes/stripe_webhooksstripe_webhooksA supabase function that gets realtime updates from stripe then updates the subscription_tier of users. Steps: 1. Verify stripe signing signature 1. Query stripe API to avoid handling any forged event 1. Update supabase the subscription tier of the user based on the stripe_customer_id Example Code Video Walkthrough
  • notes/update_subscription_tierupdate_subscription_tierA supabase function that is triggered by a database webhook when the stripe_customer_id is created or updated. 1. Check if it's a valid stripe_customer_id by querying subscriptions 1. If subscription is "active" or "trialing", then retrieve the subscription_tier from the metadata of the subscription plan. 1. Otherwise, set the subscription_tier to "free" 1. Update the row of the stripe_customer_id with the new subscription_tier let subscription_tier = 'free'; // Step 1 const sub = await stri

Database Functions

  • notes/handle_new_usershandle_new_usersA function that is run using a postgres trigger right when a new user is created Example Video