Switch language:

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:

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:

  1. Loading initial data on mount.
  2. Listening for any subsequent changes to the table.
  3. Updating local state granularly based on event type.
  4. 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

Thank you so much for reading this article. I hope it has been helpful — or even inspiring — for you as well.