~$ avb/blog
<- back to posts

How we moved MyMusic.FM from Firebase Auth to Clerk without losing a single bookmark

#replit#database#authentication
Diagram showing a technical migration path with the Firebase logo on the left, an arrow pointing to the right, and the Clerk logo on the right.

The Goal

MyMusic.FM lets people bookmark and show off their music taste. Logging in used to run on Firebase Authentication. We wanted to switch the whole login system over to Clerk — better sign-in screens, cleaner account management, and branded emails from our own domain.

There was one rule we could not break: don't lose any real data. Our production app already had real users and 176 saved bookmarks. A login migration that wiped someone's library would not be great. So the real challenge wasn't "add Clerk", it was "swap the entire identity system under a live app while keeping every account and every bookmark intact."

The What

What actually had to change?

Three things had to move at once:

  1. The login provider — from Firebase to Clerk (Google sign-in + email/password, email verification, and "re-authenticate for sensitive actions").

  2. The branding — sign-in screens and verification emails coming from our own domain instead of a generic one from Firebase. Most Firebase authentication emails land in the spam folder anyway which is bad UX.

  3. The database: every user record had to stop pointing at a Firebase ID and start pointing at a Clerk ID.

That third one is where all the risk lived.

To visualize why this was so risky under the hood, think of MyMusic.FM as a high-end club. Your users table is the master ledger at the front desk, and their saved bookmarks are luxury cars parked safely in the back garage.

Firebase Auth was our old valet service. Every time a guest arrived, Firebase checked their ID, gave them a unique ticket number (firebase_uid), and wrote that ticket number next to their name in our ledger. When they wanted their cars (bookmarks) back, the club checked the ledger for that specific ticket number.

Now, imagine firing that valet company and hiring a premium service: Clerk.
Clerk uses an entirely different ticket format. If we just blindly renamed the ticket column in our ledger, Clerk wouldn't recognize the old Firebase ticket stubs. Returning guests would show up, Clerk would assume they were brand-new, hand them an empty ticket stub, and they'd walk into the club to find an empty garage—meaning zero bookmarks.

Here is how we executed the database hand-off cleanly without losing a single car in the garage.

The How

Think of our users table as a spreadsheet. Each person had a column called firebase_uid ,  basically "this is who you are in Firebase." Clerk uses a totally different ID format, so we needed a new column, clerk_user_id, and we needed to remove the old Firebase one.


So the change was:

  • Add a new empty column: clerk_user_id

  • Remove the old column: firebase_uid

  • Add a rule that no two people can share the same clerk_user_id (a "unique" constraint)

Here's the exact set of database commands that ran in production

ALTER TABLE "users" DROP CONSTRAINT "users_firebase_uid_unique";
ALTER TABLE "users" ADD COLUMN "clerk_user_id" text;
ALTER TABLE "users" DROP COLUMN "firebase_uid";
ALTER TABLE "users" ADD CONSTRAINT "users_clerk_user_id_unique" UNIQUE("clerk_user_id");

Fortunately on Replit, the development and production databases are separate, and publishing automatically compares the two and applies the schema changes to production for you. We didn't have to hand-write a risky migration against the live database, we got the new structure working in development first, then let the publish step carry the structure (not the data) over to production.

The part that preserved everyone's bookmarks

Here's the clever bit that the Replit agent suggested. We did not copy old IDs into the new column. The new clerk_user_id column started empty for everyone.

Then we used a "re-link on first login" approach: when you sign in with Clerk for the first time, the server looks at your verified email address, finds your existing account with that same email, and quietly fills in your new Clerk ID on that row.


The result: you log in with Clerk, and your old account — with all its bookmarks — simply becomes your account. Nothing is recreated, nothing is lost. (Safety detail: we only re-link when the email is verified, so nobody can claim someone else's library by typing their email.)

The Risks

This is the part I want to be honest about, because "it just worked" hides the real story.

Risk 1: The "rename" trap (the scariest one)

When the system saw one column disappearing (firebase_uid) and another appearing (clerk_user_id), it offered to be "helpful" and rename the old column into the new one.

That sounds convenient. It would have been a catastrophe. Renaming would have stuffed everyone's old Firebase IDs into the Clerk column. Our "re-link by email" logic only runs when the Clerk ID is empty — so with the column pre-filled with ‘junk’, the re-link would never fire, and every user would have shown up as a brand-new, empty account.

How we handled it: we explicitly chose "create a new column" instead of "rename." The new column started empty, exactly as the re-link logic needs.

Risk 2: The "unique" rule on empty values

We added a rule that clerk_user_id must be unique. But right after the change, every row had an empty clerk_user_id. Wouldn't "everyone is empty" violate "everyone must be unique"?

How we handled it: in PostgreSQL, empty (NULL) values are exempt from uniqueness — you can have many empty rows, and they're only checked for duplicates once they're filled in. So adding the rule "as-is" was completely safe. We confirmed this before clicking, rather than hoping.

Risk 3: Permanently deleting a column

Dropping firebase_uid is destructiv, that data is gone for good. The publish to production step flashed a clear "this will permanently remove data" warning.

How we handled it: we accepted it on purpose, because we'd already proven we didn't need it. The whole re-link approach is built on the verified email, not the Firebase ID. Losing firebase_uid changed nothing about our ability to reconnect accounts.

Risk 4: Test keys vs. live keys

Clerk gives you test keys (for development) and live keys (for the real site). If the live site accidentally launched with test keys, logins would break. There's also a sharp edge: the public Clerk key gets baked into the app when it's built, so it has to be in place before publishing, not after.

How we handled it: we set the live keys as production-only secrets, keeping the test keys for development, using the same names so no code had to change. And we set them before hitting publish so the live build picked them up.

Risk 5: Touching the wrong database

Production data is sacred. We never wanted to accidentally read or write the live database while poking around.

How we handled it: during the whole process, every check against production was read-only. We confirmed the structure and the bookmark counts without ever modifying live data until the single, reviewed publish step.

The safety net: rollback

Even with all of the above, we kept an escape hatch. Because the schema change doesn't delete user rows or bookmarks, redeploying the previous version would have rolled us back cleanly. We were never one click away from an unrecoverable mistake.

The Result

We published. We (me and the AI ;) held our breath. Then:

  • ✅ Google sign-in worked — with our custom branding ("continue to MyMusicFM").

  • ✅ Email sign-up + the verification email worked, sent from our own domain.

  • ✅ Every account re-linked automatically, and all 176 bookmarks were exactly where they belonged.

We verified it afterward straight from the production database (read-only): 176 bookmarks intact, and the owner accounts correctly stamped with their new Clerk IDs on first login.

A full authentication system, swapped under a live app, with real user data and zero data loss.

Learnings

  • Make new things instead of "renaming" old things when identities are involved. A rename felt convenient and would have wiped everyone.

  • Re-link by verified email, and start the new ID column empty. That's what preserves data without copying anything risky.

  • Get it right in development first, then let the publish step carry the structure to production.

  • Read before you write. Every production check was read-only until the one reviewed step.

  • Keep a rollback you actually trust.

Built and shipped on Replit — the separate dev/production databases and the automatic schema-diff on publish turned the single scariest part of this migration into a reviewable, one-click step. 🚀