A few months ago I built a voicemail platform — a web app where people can record audio messages, browse voicemails from others, and react to them in real time. The backend is entirely Supabase: PostgreSQL for data, Storage for audio files, Row Level Security for auth, and Realtime for live updates. Here's what I learned building it.
Why Supabase Over Firebase?
I've used Firebase extensively (it was my go-to at Proudcloud for GCP-based projects), and it's great for rapid prototyping. But Supabase gives you a real PostgreSQL database under the hood, which means proper relational data modeling, complex queries, and the full power of SQL. For the voicemail platform, I needed joins, aggregations, and row-level security policies that would have been painful to express in Firestore's NoSQL model.
Schema Design: Think Relational First
One mistake I see developers make when coming from NoSQL backgrounds is designing Supabase schemas like document stores — stuffing JSON blobs into single columns. Resist this urge. PostgreSQL is incredibly good at relational queries, and Supabase's auto-generated API respects foreign key relationships.
-- Clean relational schema for the voicemail platform
create table voicemails (
id uuid default gen_random_uuid() primary key,
user_id uuid references auth.users not null,
audio_url text not null,
display_name text not null,
duration integer not null,
is_captain boolean default false,
created_at timestamptz default now()
);
create table reactions (
id uuid default gen_random_uuid() primary key,
voicemail_id uuid references voicemails on delete cascade,
emoji text not null,
user_identifier text not null,
created_at timestamptz default now(),
unique(voicemail_id, emoji, user_identifier)
);Real-Time Subscriptions
The killer feature of Supabase for this project was Realtime subscriptions. When someone adds a reaction to a voicemail, every other user viewing that voicemail sees the reaction count update instantly — no polling, no manual refreshes. Under the hood, Supabase uses PostgreSQL's LISTEN/NOTIFY system to push changes through WebSocket connections.
// Subscribe to real-time reaction updates
const channel = supabase
.channel('reactions')
.on(
'postgres_changes',
{
event: '*',
schema: 'public',
table: 'reactions',
filter: `voicemail_id=eq.${voicemailId}`,
},
(payload) => {
// Update local state immediately
handleReactionChange(payload);
}
)
.subscribe();Storage + CDN for Audio Files
Audio files are stored in Supabase Storage, which is backed by S3-compatible object storage with a built-in CDN. The recording flow works like this: the browser captures audio via the MediaRecorder API, converts it to a WebM blob, uploads it to a Supabase storage bucket, and stores the public URL in the voicemails table. The entire upload-to-playable pipeline takes under 2 seconds for a 30-second recording.
Row Level Security — Don't Skip It
Supabase exposes your database directly to the client via its auto-generated API. This is incredibly productive but also means you MUST use Row Level Security (RLS) policies. Without them, anyone with your anon key can read or modify any row. RLS policies are SQL expressions that run on every query and determine which rows a user can access.
Treat RLS policies like unit tests — if you don't write them, everything works until it catastrophically doesn't.
Supabase has become my default backend for new projects. It's not perfect — the dashboard can be slow, the docs sometimes lag behind features, and the free tier is limiting for production use. But the core product — Postgres + Auth + Storage + Realtime — is genuinely excellent and saves weeks of backend development time.