Building in Public#building-in-public

Firebase to Supabase: Why We Migrated and What It Actually Took

By Mohamed Abdi Guled

ShabelleHub started with Firebase and ended up on Supabase — the honest account of why, and what the migration actually took.

Firebase to Supabase: Why We Migrated

ShabelleHub started with Firebase. It ended up on Supabase. This is the honest account of why we switched, what the migration involved at the code level, and what we would do differently if we were starting today.


Why We Started With Firebase

Firebase was the obvious starting point for a solo founder building quickly. Firestore's document model requires no schema design upfront — you can start writing data in any shape and figure out structure later. Firebase Authentication handles login with a few lines of code. The free tier is generous enough to run a small site without paying anything.

For ShabelleHub's CMS — blog posts, tool data, author profiles, media uploads — Firebase worked. Pages loaded. Login worked. Data saved. The site shipped.


Why We Moved to Supabase

Three reasons accumulated over time:

1. Relational data became painful in Firestore. ShabelleHub's blog posts reference authors, categories, and tags. In Firestore, querying "all posts by this author in this category" requires either denormalized data (duplicating the author's name in every post document) or multiple round-trips (fetch the author, then query posts by author ID). In PostgreSQL, it is a single JOIN. The document model that made starting easy became friction as the data model grew.

2. Row Level Security is more expressive than Firestore security rules. Supabase's RLS policies are SQL — readable, composable, and testable with the same tools you use to query the database. Firestore security rules are a custom language with limited tooling. When the ShabelleHub CMS needed to enforce that editors could only edit their own posts, writing and testing the Supabase RLS policy was straightforward. The equivalent Firestore rules were harder to reason about.

3. The Supabase free tier met our needs. Supabase's free tier includes a PostgreSQL database, authentication, and storage — everything Firebase was providing, in a stack we preferred working with.


What the Migration Involved

The migration touched three distinct areas: authentication, database, and the API layer.

Authentication

Firebase Auth and Supabase Auth are conceptually similar — both handle email/password login, sessions, and JWT tokens — but the client APIs are different.

Firebase:

import { getAuth, signInWithEmailAndPassword } from 'firebase/auth';
const auth = getAuth();
await signInWithEmailAndPassword(auth, email, password);

Supabase:

import { createClient } from '@supabase/supabase-js';
const supabase = createClient(url, anonKey);
await supabase.auth.signInWithPassword({ email, password });

Every component that called Firebase Auth was updated to call the Supabase client. The useAuth hook in lib/cms/useAuth.js was rewritten to subscribe to supabase.auth.onAuthStateChange instead of firebase.auth().onAuthStateChange.

Database

Firestore documents became PostgreSQL rows. The data shape changed from nested objects to flat tables with foreign keys.

A Firestore blog post document:

{
  "id": "abc123",
  "title": "Article title",
  "author": {
    "id": "user456",
    "name": "Jane Doe"
  },
  "tags": ["nextjs", "supabase"],
  "published": true
}

The equivalent Supabase schema:

-- posts table
CREATE TABLE posts (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  title TEXT NOT NULL,
  author_id UUID REFERENCES profiles(id),
  published BOOLEAN DEFAULT FALSE,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

-- post_tags junction table
CREATE TABLE post_tags (
  post_id UUID REFERENCES posts(id) ON DELETE CASCADE,
  tag_id UUID REFERENCES tags(id) ON DELETE CASCADE,
  PRIMARY KEY (post_id, tag_id)
);

The query to fetch a post with its author and tags:

const { data } = await supabase
  .from('posts')
  .select(`
    id, title, published, created_at,
    author:profiles(id, name, avatar_url),
    tags(name, slug)
  `)
  .eq('id', postId)
  .single();

This returns the same shape as the Firestore document, but with a single database query rather than multiple round-trips.

The API Layer

Every API route that called the Firebase Admin SDK was updated to call the Supabase service role client instead.

Firebase Admin pattern:

import { getFirestore } from 'firebase-admin/firestore';
const db = getFirestore();
const doc = await db.collection('posts').doc(id).get();

Supabase Admin pattern:

import { createClient } from '@supabase/supabase-js';
const supabase = createClient(url, serviceRoleKey);
const { data } = await supabase.from('posts').select('*').eq('id', id).single();

The service role key bypasses Row Level Security — used only in server-side API routes, never exposed to the browser.


The Firebase References We Left Behind

After the database migration was functionally complete, we ran a scan for lingering Firebase references:

grep -rn -i "firebase" --include="*.js" . | grep -v node_modules

The scan found Firebase references in 13 files — import aliases, UI text, error messages, and comments. Examples:

// import alias that named Supabase client as Firebase
import { isSupabaseConfigured as isFirebaseConfigured } from '../../lib/supabase';

// UI text in admin panel
sub="Configure Firebase to manage users."

// Warning message
"using the Firebase Admin SDK"

None of these were functional issues — they were naming and text that referenced the old system. But they were visible to anyone using the admin panel, and they made the codebase harder to reason about.

We fixed them with sed commands run across all files:

find . -name "*.js" -not -path "*/node_modules/*" \
  -exec sed -i 's/Configure Firebase/Configure Supabase/g' {} \;
find . -name "*.js" -not -path "*/node_modules/*" \
  -exec sed -i 's/Firebase Admin SDK/Supabase Admin SDK/g' {} \;
find . -name "*.js" -not -path "*/node_modules/*" \
  -exec sed -i 's/isFirebaseConfigured/isSupabaseConfigured/g' {} \;

We also deleted pages/api/admin/posts/debug.js — a diagnostic endpoint that checked for Firebase environment variables that no longer existed.

After the cleanup, the scan returned zero results.


Environment Variables

Firebase required six environment variables. Supabase requires two for the client and one for the server:

# Firebase (removed)
NEXT_PUBLIC_FIREBASE_API_KEY=
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=
NEXT_PUBLIC_FIREBASE_PROJECT_ID=
FIREBASE_ADMIN_CLIENT_EMAIL=
FIREBASE_ADMIN_PRIVATE_KEY=

# Supabase (added)
NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ...
SUPABASE_SERVICE_ROLE_KEY=eyJ...

The reduced surface area is a minor security improvement — fewer secrets means fewer things to rotate if a key is ever exposed.


What We Would Do Differently

Start with Supabase. The relational model fits a CMS better than a document model, and knowing that from the start would have saved the migration work entirely. Firebase's faster initial setup is real, but the time saved at the start was less than the time spent on migration.

Write a data migration script, not just a schema migration. Our content volume was small enough that we could recreate it manually. For a larger site, a script that reads from Firestore and writes to Supabase (handling the shape transformation from documents to rows) would be essential.

Run the old and new systems in parallel briefly. Switching everything at once meant the site was offline-for-CMS during the transition. Running Supabase in read-only mode alongside Firebase for a week before cutting over would have been safer.


The Result

The migration took longer than expected and produced a codebase that is easier to work with. The relational model handles ShabelleHub's content relationships cleanly. RLS policies are readable. The admin panel connects to a database we understand.

The Firebase references in UI text and import aliases are gone. The debug endpoint is deleted. The environment variable count is lower. The system does what it should without carrying the history of what it used to be.


*This article is part of the ShabelleHub Building in Public series.*

*Next: Building a Custom CMS in Next.js From Scratch*

📬 Get the latest AI tool reviews

Expert picks and comparisons, weekly. No spam.

← Back to Blog