How I Built a Real-Time App with Supabase - 07/03/2026
Building a real-time app used to require custom WebSockets and dedicated servers. With Supabase Realtime, all of that disappears. Here's what I learned building capturioo.com.
Live app: capturioo.com
Building a real-time app used to require custom WebSockets, dedicated servers, and a significant amount of infrastructure. With Supabase Realtime, all of that disappears and you can have data synced across clients in minutes. Here’s what I learned building capturioo.com.
What Does “Real-Time” Mean in an App?
A real-time app is one where data changes propagate instantly to all connected clients without the user having to refresh the page. The use cases are broad:
- Shared activity feeds
- Collaborative tables or lists
- Live notifications
- Self-updating dashboards
The technical challenge is keeping that synchronization efficient, secure, and consistent.
The Core Architecture: PostgreSQL + Realtime
Supabase exposes a real-time layer on top of PostgreSQL using Logical Replication. This means that when a record changes in the database, Supabase broadcasts that event to all clients subscribed to that channel — whether it’s an INSERT, UPDATE, or DELETE.
No additional server needed. The real-time channel is managed by Supabase.
Subscribing to Changes: The Central Pattern
The pattern I used in capturioo.com to listen to real-time changes:
import { useEffect, useState } from 'react';
import { supabase } from '@/lib/supabase';
function useLiveCaptures() {
const [captures, setCaptures] = useState<Capture[]>([]);
useEffect(() => {
// Initial load
supabase
.from('captures')
.select('*')
.order('created_at', { ascending: false })
.then(({ data }) => {
if (data) setCaptures(data);
});
// Real-time subscription
const channel = supabase
.channel('captures-realtime')
.on(
'postgres_changes',
{ event: '*', schema: 'public', table: 'captures' },
(payload) => {
if (payload.eventType === 'INSERT') {
setCaptures((prev) => [payload.new as Capture, ...prev]);
}
if (payload.eventType === 'UPDATE') {
setCaptures((prev) =>
prev.map((c) => (c.id === payload.new.id ? { ...c, ...payload.new } : c))
);
}
if (payload.eventType === 'DELETE') {
setCaptures((prev) => prev.filter((c) => c.id !== payload.old.id));
}
}
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, []);
return captures;
}
This hook handles:
- Loading initial data on mount.
- Listening for any subsequent changes to the table.
- Updating local state granularly based on event type.
- Cleaning up the subscription on unmount.
Optimistic UI: The Illusion of Speed
To make the app feel instant, I used optimistic updates: applying the change to local state before Supabase confirms the operation.
async function addCapture(newCapture: NewCapture) {
const optimisticItem = { ...newCapture, id: crypto.randomUUID(), pending: true };
// Update UI immediately
setCaptures((prev) => [optimisticItem, ...prev]);
// Persist to Supabase
const { error } = await supabase.from('captures').insert(newCapture);
if (error) {
// Revert on failure
setCaptures((prev) => prev.filter((c) => c.id !== optimisticItem.id));
}
}
The Realtime event that arrives afterward simply updates the temporary record with the real server ID.
Presence: Knowing Who’s Connected
Supabase Realtime also offers Presence, which lets you track which users are active in a channel in real time. I used it to show activity indicators:
const channel = supabase.channel('room', {
config: { presence: { key: userId } },
});
channel.on('presence', { event: 'sync' }, () => {
const state = channel.presenceState();
setActiveUsers(Object.keys(state));
});
channel.subscribe(async (status) => {
if (status === 'SUBSCRIBED') {
await channel.track({ user_id: userId, online_at: new Date().toISOString() });
}
});
Row Level Security: Safe Real-Time
Real-time doesn’t disable RLS. Each event that arrives at a client is filtered by the table’s security policies. This is critical: users only receive events about records they have access to, regardless of how many changes happen in the database.
-- Only receive events for your own captures
CREATE POLICY "Users can see their own captures"
ON captures FOR SELECT
USING (auth.uid() = user_id);
Lessons Learned
- Don’t poll: Supabase Realtime is more efficient and simpler.
- Granular event handlers: handling
INSERT,UPDATE, andDELETEseparately avoids unnecessary re-renders. - Always clean up your channels:
removeChannelin theuseEffectcleanup is mandatory to avoid memory leaks. - Optimistic UI + Realtime: the perfect combination for a fluid experience.
- RLS is your first line of defense: configure it before enabling real-time.
Thank you so much for reading this article. I hope it has been helpful — or even inspiring — for you as well.