Migrating from Firebase to Supabase: a real app, end to end (Firestore to Postgres, Auth, Storage, RLS) — Cesar Ayala
← All posts

Migrating from Firebase to Supabase: a real app, end to end (Firestore to Postgres, Auth, Storage, RLS)

Use the supabase-community/firebase-to-supabase repo: collections.js lists collections, firestore2json.js exports each to JSON, json2supabase.js imports it into Postgres. The real work is the NoSQL-to-relational shift — nested objects default to jsonb, so denormalize into related tables with foreign keys before importing. Then migrate Auth (scrypt hashes preserved, no reset), Storage, and rewrite Security Rules as RLS.

Why teams leave Firebase for Supabase (and what actually has to change)

To migrate a real app from Firebase to Supabase, use the community firebase-to-supabase repo. Its firestore/ directory ships three scripts you run in order: collections.js lists your Firestore collections, firestore2json.js exports each one to a local JSON file, and json2supabase.js imports that JSON into a Postgres table. The tooling is the easy part. The real engineering is the NoSQL-to-relational shift: Firestore’s nested documents default to jsonb columns, so you denormalize the embedded data into related tables with foreign keys before you import. After the data lands, you migrate Auth (Firebase’s scrypt password hashes are preserved, so no forced reset), move Storage, and rewrite Security Rules as Row Level Security. This post walks the whole thing, end to end.

Firestore (NoSQL)Postgres tables (relational)
Firebase AuthSupabase Auth (auth.users), hashes kept
Firebase StorageSupabase Storage buckets
Security RulesRLS policies (a rewrite, not a migration)
Toolinggithub.com/supabase-community/firebase-to-supabase
Hard partDenormalizing nested docs into tables

Most migrations I do start with the same three complaints: Firestore reads and writes get expensive at scale, querying a document store for anything relational is painful, and auth, database, and storage live in separate mental models. Supabase collapses that into one Postgres instance. You get real SQL joins, foreign keys, and constraints; RLS policies that live next to the data instead of in a separate rules file; and predictable pricing. If you are still weighing the auth layer specifically, I compared the options in Better Auth vs Clerk vs Supabase for a Mexican SaaS, and the total cost picture is in how much it costs to build a SaaS MVP.

Here is the honest part: three of the four pieces are mechanical. Auth, Storage, and the data export all have scripts. The one thing no script can do for you is redesign your data model. Firestore lets you embed an array of orders inside a user document; Postgres wants those orders in their own table with a user_id foreign key. Getting that mapping right is the whole job.

Set up the migration repo: firebase-service.json + supabase-service.json

Clone the repo and install dependencies. Everything runs on Node.

git clone https://github.com/supabase-community/firebase-to-supabase.git
cd firebase-to-supabase/firestore
npm install

The scripts authenticate against both platforms using two service-account files you drop into the working directory. firebase-service.json holds your Firebase credentials (from the Firebase Console, Project Settings, Service Accounts, Generate new private key). supabase-service.json holds your Supabase Postgres connection details. Create the second one yourself:

{
  "host": "db.YOUR_PROJECT_REF.supabase.co",
  "password": "your-database-password",
  "user": "postgres",
  "database": "postgres",
  "port": 5432
}

Keep both files out of version control. Add them to .gitignore immediately — they are full-access credentials to both systems.

Export Firestore: collections.js, then firestore2json.js

First, list what you actually have. Firestore does not give you a schema, so collections.js enumerates your top-level collections:

node collections.js

That prints something like users, orders, products. Now export each collection to a JSON file with firestore2json.js. You pass the collection name; the output file is written automatically as <collectionName>.json, and the two optional arguments are batch size and a row limit:

node firestore2json.js users
node firestore2json.js products

The first command writes users.json, the second products.json. Each file is a JSON array where every element is one Firestore document, with its fields flattened to plain keys. Open the files and read them before you go further — this is where you discover which fields are nested objects and which are embedded arrays, and that determines your table design.

  1. collections.jsLists your Firestore collections so you know what to export
  2. firestore2json.jsExports one collection to a local JSON file
  3. Custom hook or transform (HOOKS.md)Reshapes the JSON — split embedded arrays into their own files
  4. json2supabase.jsImports a JSON file into a Postgres table

The model shift: collection to table, nested objects to jsonb

Here is exactly how json2supabase.js maps Firestore onto Postgres. A collection becomes a table. Each scalar field becomes a typed column: strings become text, numbers become numeric, booleans become boolean. Anything that is a nested object or array — a sub-document, an embedded list — lands as a single jsonb column by default.

That default is a trap if you accept it blindly. A jsonb blob is not queryable the way a relational column is, you cannot put a foreign key on it, and you lose the whole reason you moved to Postgres. jsonb is the right choice for genuinely schemaless payloads (a flexible metadata field, a raw webhook body). It is the wrong choice for structured, repeating data like line items or orders. So before importing, you decide field by field: keep as jsonb, or promote to its own table.

Leave it as jsonb (default)

  • Zero extra work — json2supabase does it automatically
  • Good for flexible, schemaless metadata
  • No foreign keys, no per-field constraints
  • Awkward to query and join
  • Wrong for structured, repeating data

Denormalize into a related table

  • Split the array into its own JSON file first
  • Real columns, real types, real indexes
  • Foreign key back to the parent row
  • Joins and RLS work naturally
  • This is the actual migration work

Denormalize before you import: an embedded orders array to users + orders tables

Take a concrete case. Your Firestore users collection has documents shaped like this — each user embeds an array of their orders:

{
  "id": "user_abc123",
  "email": "ana@example.com",
  "displayName": "Ana",
  "orders": [
    { "orderId": "ord_1", "total": 499.00, "status": "paid" },
    { "orderId": "ord_2", "total": 120.50, "status": "pending" }
  ]
}

If you import that as-is, you get a users table with an orders column of type jsonb. Instead, split it into two tables. The repo’s supported reshaping mechanism is a custom hook (documented in HOOKS.md) — or your own small transform script — that strips the orders array off each user and emits a flat orders.json where every element carries its parent user_id. You want two files: a users.json without the embedded array, and an orders.json that looks like this:

[
  { "orderId": "ord_1", "userId": "user_abc123", "total": 499.00, "status": "paid" },
  { "orderId": "ord_2", "userId": "user_abc123", "total": 120.50, "status": "pending" }
]

Then create the target schema in Postgres with the relationship made explicit, before you import anything:

create table public.users (
  id text primary key,
  email text not null,
  display_name text
);

create table public.orders (
  order_id text primary key,
  user_id text not null references public.users(id),
  total numeric not null,
  status text not null default 'pending'
);

create index orders_user_id_idx on public.orders(user_id);

Now you have a foreign key, an index, and two tables you can actually join. That reshaping — done for every embedded array and every sub-document in your app — is 80 percent of the effort. The scripts are the other 20.

Import into Postgres with json2supabase.js

With the schema in place and the JSON reshaped, import each file. json2supabase.js takes the path to a JSON file and an optional primary-key strategy:

node json2supabase.js ./users.json
node json2supabase.js ./orders.json

Import parents before children so the foreign key references resolve — users before orders, always. After each run, sanity-check the row counts and spot-check a few records against Firestore:

select count(*) from public.users;
select count(*) from public.orders;
select o.order_id, o.total, u.email
from public.orders o
join public.users u on u.id = o.user_id
limit 5;

If that join returns rows, your model shift worked. This is the moment the migration stops being NoSQL and starts being a real relational database.

Migrate Firebase Auth: scrypt hashes preserved, no password reset

Auth is its own scripted pipeline, and it is the part that most surprises people: your users keep their existing passwords, no reset email required. The auth/ directory in the same repo has two scripts. firestoreusers2json.js exports your Firebase users to JSON, and import_users.js loads them into Supabase’s auth.users table:

node firestoreusers2json.js users_export.json 100
node import_users.js ./users_export.json 100

The second argument is the batch size. The magic is that Supabase’s auth server (GoTrue) can verify Firebase’s scrypt password hashes directly, given four parameters you copy from the Firebase Console under Authentication, then the password-hash parameters panel: base64_signer_key, base64_salt_separator, rounds (for example, 8), and mem_cost (for example, 14). Feed those into the import and every existing user logs in with the same password they already had.

There is a second, lower-risk pattern too: a first-login verification middleware that checks the old Firebase credential once and rehashes it into Supabase on the fly. Either way, no mass reset. I go deep on both approaches, including the exact parameter mapping, in the dedicated migrate Firebase Auth to Supabase walkthrough, and the Firestore-to-Postgres data migration sibling drills into the schema-mapping specifics — pair both with this one.

The other two pieces: Storage and Security Rules to RLS

Two things remain. Storage is the gentle one — the repo’s storage/ directory has helpers to move files from Firebase Storage into Supabase Storage buckets. Recreate your bucket structure in Supabase, copy the objects, and update the file paths your app references. There is no data-model puzzle here.

Security Rules to RLS is the opposite: there is no import, and there cannot be one. Firebase Security Rules and Postgres Row Level Security are different systems. You rewrite each rule as an RLS policy, and you test every single one. The good news is the mental model is cleaner — a policy is just a SQL predicate keyed on auth.uid():

alter table public.orders enable row level security;

create policy "users read their own orders"
on public.orders for select
to authenticated
using ( auth.uid()::text = user_id );

create policy "users insert their own orders"
on public.orders for insert
to authenticated
with check ( auth.uid()::text = user_id );

Treat RLS as its own task with its own test plan. A missing policy on a table with RLS enabled means no one can read it; a too-loose policy means everyone can. Enable RLS on every public table and write policies deliberately, per the Supabase RLS guidance.

Cutover checklist

Do a dry run into a staging Supabase project first. Then, when you cut over for real, work down this list in order:

Freeze Firestore writes
Export + reshape JSON
Import parents then children
Migrate Auth users
Copy Storage
Enable + test RLS
Point app at Supabase

Concretely: put the app in maintenance mode to stop Firestore writes, run the export and reshape, import parent tables before child tables, run the Auth migration with your scrypt parameters, copy Storage objects and fix paths, enable RLS and run every policy through a test, then flip your client config from Firebase to the Supabase URL and anon key. Verify a full round trip — sign in as an existing user with their old password, read a record protected by RLS, write a new one — before you take down maintenance mode.

One standing caveat: the firebase-to-supabase repo evolves, and the Firebase Console occasionally moves the password-hash parameter panel. Pin the exact script names and the parameter location against the live repo and the official Firestore migration guide at the moment you run this — do not trust a cached version. Get the data model right, keep the passwords, rewrite the rules with intent, and a real app moves from Firebase to Supabase cleanly.