DEV Community

Cover image for Moving a Live SaaS from Firebase to Supabase: 283 Functions, 139 Tables, Zero Password Resets
Joshua
Joshua

Posted on Originally published at hafenpixel.de

Moving a Live SaaS from Firebase to Supabase: 283 Functions, 139 Tables, Zero Password Resets

In mid-August 2026, LaizyNote ran entirely on Firebase: data in Firestore, logic in Cloud Functions, sign-in via Firebase Auth. Four weeks later it ran on Supabase — same users, same subscriptions, and nobody had to reset their password.

Here's how that worked while the product stayed live, and what went wrong along the way.

The migration in numbers

  • 283 Firebase functions inventoried and decided one by one
  • 139 tables, all with Row Level Security
  • 206 SQL migrations in the repository
  • 3 weeks from first code to the auth cutover
  • 0 password resets

Why move at all?

Not for cost. The migration plan says it explicitly: Firebase would have stayed cheaper than any rebuild. The driver was data sovereignty.

LaizyNote stores freelancers' business data — clients, revenue, hours. For a product on a .eu domain, it should be clear where that data lives and who controls it. With Supabase it lives in Frankfurt, and the whole data model is kept as SQL migrations in the repo. If needed, the system could be self-hosted instead of reconstructed from a cloud console.

A second reason showed up during planning: real columns instead of documents. Many content types had a personal and a team collection in Firestore, maintained in parallel. In Postgres that's one table with an owner and an optional workspace — roughly half the data structures.

283 functions, decided one by one

Before anything moved, every function Firebase ran — callables, scheduled jobs, document triggers — got an entry in a ledger. Each entry has exactly one status:

Status Meaning Count
active successor exists and is wired up 253
offline successor exists, deliberately not used yet 19
decommission no longer needed 11
blocked no complete successor yet 0

The decommissioned ones are the interesting part. A nightly job that corrected usage counters became pointless because Postgres now checks limits inside the same transaction. Password-reset and verification mails are handled by Supabase Auth. A one-time repair function was replaced by a default value.

The ledger isn't a spreadsheet: a script parses the code and fails on any function without a registered successor. Nothing could slip through, not even in the rush before cutover.

One switch per area

The key building block: every part of the app talks to a gateway instead of calling Firebase directly — one for notes, one for projects, one for auth, and so on. Each gateway had a Firebase adapter first and a Supabase adapter later, selected by one environment switch per area.

Two details paid off:

  • Only one exact value switches. Any typo meant Firebase, so a typo could never point the app at an empty database.
  • No silent fallback. Switch set to Supabase but no client configured? The app fails loudly instead of quietly using Firebase.

On August 31 the transition mode ran in the browser for the first time: Firebase signed the user in, Postgres served the notes. That worked because user IDs are computed, not looked up: each new UUID is derived deterministically from the old Firebase UID. The import could be re-run any number of times without links shifting.

Data came over through 46 import scripts, one per domain. Fields that didn't fit a clean column yet went into a legacy JSON column first — nothing lost, even if not yet modelled.

Plan versus reality

The original plan said: one long branch, no dual backend, no Edge Functions. Both got built.

The dual setup happened because it spreads risk — each area could be switched, verified and switched back on its own. Edge Functions came in on September 4, once it was clear Google should leave the system entirely, Cloud Functions included.

A plan is a hypothesis. Changing it isn't the failure; changing it without documenting why would be.

From Firestore Rules to Row Level Security

Every rule was rewritten as a Postgres policy, many with a comment pointing at the line in the old firestore.rules. Result: 139 tables, all with RLS, 141 policies, all using one central function to resolve the current user — none reads it on its own.

Columns users must not change (like their account type) are additionally protected with column-level grants as an allow-list.

Five bugs green tests would have missed

  1. A revoke that does nothing. In Postgres, revoking a single column privilege has no effect if the role was granted the whole table earlier — you just get a warning. A guest could have promoted themselves. The check that policies exist was green; only a test probing what they do caught it.
  2. Empty isn't unset. JavaScript's || falls through on ''; SQL's coalesce only on NULL. A customer with an empty bonus field would have lost their bonus.
  3. A function that throws instead of returning NULL. During the transition, Firebase UIDs still flowed through the system. A built-in tried to cast them to UUID and raised an error — six storage policies blocked every query.
  4. An empty table is silent data loss. A settings table existed, grants and all, with zero rows. Switching over would have wiped 17 accounts' settings. Since then a reconciliation script counts every table against its Firestore source.
  5. Dead error branches. The new gateways throw stable error codes, but a dozen call sites still compared against raw Firebase strings. Those branches never ran — including the lockout after repeated failed sign-ins. A dedicated test now prevents provider error strings from leaking.

The lesson from all five: in a migration, checking that something is there isn't enough. Check that it does what it should, with real data, from the user's side.

Cutover day

Billing and the Stripe webhook moved on September 9, authentication on September 10:

  • Passwords: Firebase's scrypt hashes were imported into Supabase Auth in a compatible format. Nobody set a new password.
  • User IDs: every account got exactly the UUID derived from its Firebase UID — all links stayed valid.
  • Subscriptions: existing Stripe customers are found via a mapping table, older ones additionally via the legacy Firebase UID in their Stripe metadata.

Before the switch: ~5,700 unit tests, ~1,800 server function tests, 303 policy checks, and a core-data reconciliation with not a single ID missing. 70 orphaned rows that no longer existed in Firebase were deleted after explicit approval.

On September 13, Firebase was removed from the client. That commit touched 279 files and deleted more than 24,000 lines.

What got easier — and what didn't

Easier Harder
One table per content type instead of personal + team copy No built-in offline write queue — built a custom IndexedDB outbox
Joins in access rules are cheap No App Check equivalent — accepted deliberately
Limits checked atomically in the database Realtime delivers single rows, not query results
Scheduled data jobs directly in Postgres (pg_cron) No direct equivalent for Firestore triggers
Supabase handles auth mails Tighter CPU and memory limits for Edge Functions

What I take away

The migration worked because it was reversible: every area could be switched and switched back, every function had a recorded status, every import could be re-run. A platform switch isn't one big leap — it's many small ones you can verify individually.

And: Firebase was the right choice to start with. Migrate when you have a clear reason and a project big enough to benefit. Without a reason, you're just trading known problems for unknown ones.

Full write-up on hafenpixel.de. Related: how LaizyNote's Stripe billing works on Supabase. Questions welcome in the comments.

Top comments (0)