Migrate from Firestore

Move Firestore collections into Postgres tables on SupaHost: design the schema, export documents with the Admin SDK, flatten them, load them in batches and switch your queries.

Moving from Firestore to SupaHost means turning collections of documents into Postgres tables. There's no automatic converter: you design the tables, export the documents with the Firebase Admin SDK, reshape them in a script, and insert them through the Data API with the service_role key. For most apps this is a few hundred lines of code and a careful afternoon.

Plan the schema

Map each collection to a table and each subcollection to a child table with a foreign key. Fields you always read become columns; loose or rarely queried data can go in a jsonb column.

For a recipe app with recipes/{id} and recipes/{id}/steps/{id}:

create table public.recipes (
  id uuid primary key default gen_random_uuid(),
  firestore_id text unique not null,
  owner_id uuid references auth.users (id),
  title text not null,
  tags text[] default '{}',
  extra jsonb default '{}',
  created_at timestamptz default now()
);

create table public.recipe_steps (
  id bigint generated always as identity primary key,
  recipe_id uuid not null references public.recipes (id) on delete cascade,
  position int not null,
  body text not null
);

alter table public.recipes enable row level security;
alter table public.recipe_steps enable row level security;

Keeping firestore_id makes the load repeatable and lets you join old references. Migrate Auth users first (Migrate from Firebase Auth) so you can turn Firebase UIDs into Supabase user IDs.

Export from Firestore

import { initializeApp, cert } from 'firebase-admin/app'
import { getFirestore } from 'firebase-admin/firestore'
import { writeFileSync } from 'node:fs'

initializeApp({ credential: cert('./service-account.json') })
const db = getFirestore()

const snap = await db.collection('recipes').get()
const rows = []
for (const doc of snap.docs) {
  const steps = await doc.ref.collection('steps').orderBy('position').get()
  rows.push({ id: doc.id, ...doc.data(), steps: steps.docs.map((s) => s.data()) })
}
writeFileSync('recipes.json', JSON.stringify(rows))

Convert Firestore Timestamp values with .toDate().toISOString(), and turn references into IDs, before writing the file.

Transform and load

Build a map from Firebase UID to the new user ID (the Firebase Auth guide stores firebase_uid in user_metadata), then insert in batches:

import { createClient } from '@supabase/supabase-js'

const admin = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!)

for (let i = 0; i < rows.length; i += 500) {
  const batch = rows.slice(i, i + 500)
  const { data, error } = await admin
    .from('recipes')
    .upsert(
      batch.map((r) => ({
        firestore_id: r.id,
        owner_id: uidMap.get(r.ownerUid) ?? null,
        title: r.title,
        tags: r.tags ?? [],
        extra: { servings: r.servings },
      })),
      { onConflict: 'firestore_id' }
    )
    .select('id, firestore_id')
  if (error) throw error
  // then insert recipe_steps using the returned ids
}

upsert with onConflict means you can rerun the script after a failure. Responses return at most 1000 rows, so keep batches under that. On Business you can instead write CSV files and load them with \copy through the SSH tunnel, which is faster for millions of rows.

Rewrite security rules as RLS

Firestore security rules become RLS policies:

create policy "owners read their recipes" on public.recipes
  for select to authenticated using ((select auth.uid()) = owner_id);

Switch the app

Firestore supabase-js
getDocs(query(col, where('owner', '==', uid))) supabase.from('recipes').select().eq('owner_id', uid)
addDoc(col, data) supabase.from('recipes').insert(data)
onSnapshot(...) supabase.channel('recipes').on('postgres_changes', ...)

Verify

Compare document counts with select count(*) from public.recipes, spot-check a few records, and test as a signed-in user to confirm the policies return the right rows.

Updated 11 November 2026 · View as Markdown · Something wrong or missing? Tell us