💪
// the app this post is about
Muscles — Workout Tracker
www.muscles-app.com

I've been building Muscles — my iOS workout tracker — for a couple of years now. It started as a scratch-your-own-itch project: I train consistently and couldn't find an app that gave me the data I actually wanted without burying it behind a subscription paywall and a gamified UI nobody asked for. So I built my own.

For the first two years, Muscles ran entirely on Firebase. Auth, Firestore for workout data, Storage for user assets. It got the job done and got me shipping fast. But as the app grew and I started looking at query costs and data model limitations more seriously, the cracks became impossible to ignore.

Firestore's pricing punishes read-heavy workloads. A workout tracker where users open the app every day and scroll through their history is exactly that kind of workload. And being a backend Java engineer by day, I kept wanting SQL — proper joins, foreign keys, the ability to write a query that still makes sense six months later.

Supabase was the obvious answer. Postgres underneath, a clean REST API via PostgREST, open source, and a Swift SDK that had matured enough to trust in production. This post is the full technical breakdown — what I did, what broke, and what I'd do differently.

// before you start

This migration was designed to be zero-downtime. Every service was swapped independently behind a feature flag. At no point did any Muscles user get signed out or lose data. Read the whole approach before touching any code — the architecture decision upfront is what makes the rest easy.

01

01 — The feature flag pattern

The single most important decision has nothing to do with Supabase. It's this: every Firebase call in Muscles goes through a Swift protocol. Auth, database, storage — all abstracted. Both implementations conform to these protocols. A feature flag decides which gets injected at runtime.

This makes the migration reversible at any commit. It also means the view and view model code is untouched — they're already talking to an abstraction, not to Firebase directly.

Add the Supabase Swift SDK via Xcode → Package Dependencies: https://github.com/supabase/supabase-swift. Keep Firebase packages in place for now, then create the Supabase singleton and config:

Swift
// SupabaseManager.swift import Supabase final class SupabaseManager { static let shared = SupabaseManager() let client: SupabaseClient private init() { client = SupabaseClient( supabaseURL: URL(string: Bundle.main.supabaseURL)!, supabaseKey: Bundle.main.supabaseAnonKey ) } } // MigrationConfig.swift enum MigrationConfig { static var useSupabaseAuth = false static var useSupabaseDatabase = false static var useSupabaseStorage = false }

Flip each flag to true as you complete and validate each phase. Simple, auditable, reversible.

02

02 — Authentication

Auth is the most sensitive step. Getting it wrong means users get signed out mid-session — the kind of bug that earns you a one-star review and a support email at the same time. The strategy: introduce the protocol, run both implementations in parallel for a release cycle, then cut over cleanly.

The protocol

Swift
protocol AuthServiceProtocol { var currentUserID: String? { get } func signIn(email: String, password: String) async throws func signUp(email: String, password: String) async throws func signOut() throws var authStateStream: AsyncStream<AuthState> { get } } enum AuthState { case signedIn(String) // carries the user ID case signedOut }

The Supabase implementation

Swift
final class SupabaseAuthService: AuthServiceProtocol { private let client = SupabaseManager.shared.client var currentUserID: String? { get async { try? await client.auth.session.user.id.uuidString } } func signIn(email: String, password: String) async throws { try await client.auth.signIn(email: email, password: password) } func signUp(email: String, password: String) async throws { try await client.auth.signUp(email: email, password: password) } func signOut() throws { Task { try await client.auth.signOut() } } var authStateStream: AsyncStream<AuthState> { AsyncStream { continuation in Task { for await (event, session) in client.auth.authStateChanges { switch event { case .signedIn: continuation.yield(.signedIn(session!.user.id.uuidString)) default: continuation.yield(.signedOut) } } } } } }

Migrating existing users

You can't migrate hashed passwords — that's simply not how password hashing works. The cleanest path: a Node script creates matching Supabase accounts for every Firebase user, then on their first login to the Supabase-backed app they're prompted to reset their password. One-time, minimal friction.

Node.js
const admin = require('firebase-admin'); const { createClient } = require('@supabase/supabase-js'); const supabase = createClient(process.env.SUPABASE_URL, process.env.SERVICE_ROLE_KEY); async function migrateUsers() { let pageToken; let migrated = 0, skipped = 0; do { const result = await admin.auth().listUsers(1000, pageToken); for (const user of result.users) { const { error } = await supabase.auth.admin.createUser({ email: user.email, email_confirm: true, user_metadata: { firebase_uid: user.uid } }); error ? skipped++ : migrated++; } pageToken = result.pageToken; } while (pageToken); console.log(`Migrated: ${migrated} Skipped: ${skipped}`); } migrateUsers();
// important

The SERVICE_ROLE_KEY bypasses Row Level Security. Never ship this key in your app. Run this script locally or in a secure CI environment only.

03

03 — Database: Firestore → Postgres

This is the biggest conceptual shift and, honestly, the most satisfying part. Firestore pushes you to denormalise everything. Postgres wants the opposite. For Muscles, the data model is inherently relational — workouts have sets, sets belong to workouts. That's a foreign key, not an embedded array.

Schema design

In Firestore, Muscles stored workout data as a subcollection: users/{uid}/workouts/{workoutId} with sets embedded as an array inside each workout document. In Postgres, that becomes two clean tables with a proper FK:

SQL
CREATE TABLE public.workouts ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, name TEXT NOT NULL, notes TEXT, performed_at TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ DEFAULT NOW() ); CREATE TABLE public.sets ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), workout_id UUID NOT NULL REFERENCES public.workouts(id) ON DELETE CASCADE, exercise TEXT NOT NULL, reps INTEGER, weight_kg NUMERIC(6,2), position INTEGER NOT NULL DEFAULT 0 );

Row Level Security — non-negotiable

Supabase exposes your database directly to the client via the anon key. RLS is what stands between your users' data and every other user. Enable it on every table before going live. No exceptions.

SQL
ALTER TABLE public.workouts ENABLE ROW LEVEL SECURITY; ALTER TABLE public.sets ENABLE ROW LEVEL SECURITY; CREATE POLICY "workouts_owner" ON public.workouts FOR ALL USING (auth.uid() = user_id); CREATE POLICY "sets_owner" ON public.sets FOR ALL USING ( EXISTS ( SELECT 1 FROM public.workouts w WHERE w.id = sets.workout_id AND w.user_id = auth.uid() ) );

Replacing Firestore queries in Swift

Firebase FirestoreSupabase PostgREST
.collection("workouts").getDocuments()client.from("workouts").select()
.whereField("uid", isEqualTo: uid).eq("user_id", value: uid)
.order("date", descending: true).order("performed_at", ascending: false)
.limit(to: 20).limit(20)
.addDocument(data: dict).insert(WorkoutRow(...))
.updateData(dict).update(dict).eq("id", value: id)
.document(id).delete().delete().eq("id", value: id)

Add CodingKeys for snake_case mapping to your Swift structs and decoding from Supabase just works:

Swift
struct Workout: Codable, Identifiable { let id: UUID let userID: UUID var name: String var notes: String? var performedAt: Date enum CodingKeys: String, CodingKey { case id, name, notes case userID = "user_id" case performedAt = "performed_at" } } func fetchWorkouts(userID: UUID) async throws -> [Workout] { try await SupabaseManager.shared.client .from("workouts") .select() .eq("user_id", value: userID) .order("performed_at", ascending: false) .execute() .value }
04

04 — Realtime subscriptions

Firestore's addSnapshotListener is one of its genuinely great features. Supabase Realtime does the same thing over WebSockets. Enable your tables in Dashboard → Database → Replication, then subscribe in Swift:

Swift
func subscribeToWorkouts() async { let channel = await SupabaseManager.shared.client .realtimeV2.channel("public:workouts") let changes = await channel.postgresChange( AnyAction.self, schema: "public", table: "workouts" ) await channel.subscribe() for await change in changes { switch change { case .insert(let a): handleInsert(try? a.record.decode(as: Workout.self)) case .update(let a): handleUpdate(try? a.record.decode(as: Workout.self)) case .delete(let a): handleDelete(try? a.oldRecord.decode(as: Workout.self)) } } }
05

05 — Moving the actual data

With schema and RLS solid, it was time to move Muscles' actual workout history. A Node script reads every Firestore document, transforms field names from camelCase to snake_case, and upserts to Supabase in batches of 500. Fully idempotent — safe to re-run as many times as needed.

Node.js
async function migrateWorkouts() { const snapshot = await db.collectionGroup('workouts').get(); const batch = []; for (const doc of snapshot.docs) { const d = doc.data(); batch.push({ id: doc.id, user_id: doc.ref.parent.parent.id, name: d.name, notes: d.notes ?? null, performed_at: d.date.toDate().toISOString(), created_at: d.createdAt.toDate().toISOString(), }); if (batch.length === 500) { await supabase.from('workouts').upsert(batch, { onConflict: 'id' }); batch.splice(0); } } if (batch.length) await supabase.from('workouts').upsert(batch, { onConflict: 'id' }); console.log(`Migrated ${snapshot.size} workouts`); }
// validate before going live

After running the script, compare SELECT COUNT(*) from each Supabase table against Firebase console document counts. Spot-check 10–20 random rows to verify field values. Don't skip this. Silent data corruption is the worst kind.

06

06 — Storage

Storage was the simplest phase. Supabase Storage is S3-compatible buckets with RLS on top. Create a bucket mirroring your Firebase folder structure, set a policy, and the Swift SDK swap is nearly identical to the auth swap:

Firebase StorageSupabase Storage
Storage.storage().reference()client.storage.from("avatars")
ref.putData(data, metadata:).upload(path, data: data)
ref.downloadURL { url, _ in }.createSignedURL(path, expiresIn: 3600)
ref.delete().remove(paths: [path])

For existing files: a short script downloads each file from Firebase via the Admin SDK and re-uploads it to Supabase. Run it in a low-traffic window and keep Firebase Storage live until you've confirmed everything came across correctly.

07

07 — Cleanup

Once every flag was flipped and Muscles had been running on Supabase through a full release cycle without issues, I removed Firebase entirely.

  1. Delete all Firebase SPM packages from Xcode
  2. Remove GoogleService-Info.plist from the project
  3. Delete FirebaseApp.configure() from App.swift
  4. Global search for import Firebase — zero results
  5. Full build in Debug and Release — no warnings, no errors
  6. TestFlight build, monitor crash-free rate for one week

I kept the Firebase project in read-only mode for 30 days, then downgraded to the free Spark plan. Two months later I deleted it entirely.

// the takeaway

The Muscles migration took about three weekends of actual work. Most of that was the Postgres schema and RLS policies — getting the data model right, validating the migration script, doing the spot checks. The Swift code changes were fast because the protocol abstraction was in place from the start. Invest in the architecture upfront, and the actual swap is just plumbing.

If you're using Muscles and want to check out what the app looks like post-migration, head to muscles-app.com.