DEV Community

Cover image for Supabase Realtime: Complete Guide to Building Live Applications
Mahdi BEN RHOUMA
Mahdi BEN RHOUMA

Posted on • Originally published at iloveblogs.blog

Supabase Realtime: Complete Guide to Building Live Applications

Supabase Realtime: Complete Guide to Building Live Applications

Supabase Realtime enables you to build live, collaborative applications with minimal code. This guide covers everything from basic subscriptions to advanced patterns.

What is Supabase Realtime?

Supabase Realtime is built on top of PostgreSQL's replication functionality and provides three main features:

  1. Postgres Changes - Listen to database changes (INSERT, UPDATE, DELETE)
  2. Presence - Track and sync user state across clients
  3. Broadcast - Send ephemeral messages between clients

Architecture Overview

Client → Supabase Realtime Server → PostgreSQL
   ↓                                      ↓
WebSocket                          Replication Slot
   ↓                                      ↓
Live Updates ← ← ← ← ← ← ← ← ← ← ← ← ← ← ←
Enter fullscreen mode Exit fullscreen mode

Setup and Configuration

1. Enable Realtime on Tables

-- Enable realtime for a table
ALTER TABLE messages REPLICA IDENTITY FULL;

-- Or in Supabase Dashboard:
-- Database → Replication → Enable for specific tables
Enter fullscreen mode Exit fullscreen mode

2. Row Level Security (RLS)

-- Enable RLS
ALTER TABLE messages ENABLE ROW LEVEL SECURITY;

-- Policy for reading messages
CREATE POLICY "Users can read messages in their channels"
ON messages FOR SELECT
USING (
  auth.uid() IN (
    SELECT user_id FROM channel_members 
    WHERE channel_id = messages.channel_id
  )
);

-- Policy for inserting messages
CREATE POLICY "Users can insert messages in their channels"
ON messages FOR INSERT
WITH CHECK (
  auth.uid() IN (
    SELECT user_id FROM channel_members 
    WHERE channel_id = messages.channel_id
  )
);
Enter fullscreen mode Exit fullscreen mode

Postgres Changes: Database Subscriptions

Basic Subscription

import { createClientComponentClient } from '@supabase/auth-helpers-nextjs';
import { useEffect, useState } from 'react';

export default function Messages() {
  const [messages, setMessages] = useState([]);
  const supabase = createClientComponentClient();

  useEffect(() => {
    // Fetch initial data
    const fetchMessages = async () => {
      const { data } = await supabase
        .from('messages')
        .select('*')
        .order('created_at', { ascending: true });

      setMessages(data || []);
    };

    fetchMessages();

    // Subscribe to new messages
    const channel = supabase
      .channel('messages')
      .on(
        'postgres_changes',
        {
          event: 'INSERT',
          schema: 'public',
          table: 'messages'
        },
        (payload) => {
          setMessages(prev => [...prev, payload.new]);
        }
      )
      .subscribe();

    return () => {
      supabase.removeChannel(channel);
    };
  }, [supabase]);

  return (
    <div>
      {messages.map(msg => (
        <div key={msg.id}>{msg.content}</div>
      ))}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Listen to All Events

const channel = supabase
  .channel('all-changes')
  .on(
    'postgres_changes',
    {
      event: '*', // INSERT, UPDATE, DELETE
      schema: 'public',
      table: 'messages'
    },
    (payload) => {
      console.log('Change received!', payload);

      switch (payload.eventType) {
        case 'INSERT':
          setMessages(prev => [...prev, payload.new]);
          break;
        case 'UPDATE':
          setMessages(prev => 
            prev.map(msg => 
              msg.id === payload.new.id ? payload.new : msg
            )
          );
          break;
        case 'DELETE':
          setMessages(prev => 
            prev.filter(msg => msg.id !== payload.old.id)
          );
          break;
      }
    }
  )
  .subscribe();
Enter fullscreen mode Exit fullscreen mode

Filter by Column Value

// Only listen to messages in a specific channel
const channel = supabase
  .channel('channel-messages')
  .on(
    'postgres_changes',
    {
      event: 'INSERT',
      schema: 'public',
      table: 'messages',
      filter: `channel_id=eq.${channelId}` // Filter by channel
    },
    (payload) => {
      setMessages(prev => [...prev, payload.new]);
    }
  )
  .subscribe();
Enter fullscreen mode Exit fullscreen mode

Presence: Track Online Users

Basic Presence Implementation

'use client';

import { useEffect, useState } from 'react';
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs';

export default function OnlineUsers({ roomId, currentUser }) {
  const [onlineUsers, setOnlineUsers] = useState([]);
  const supabase = createClientComponentClient();

  useEffect(() => {
    const channel = supabase.channel(`room:${roomId}`, {
      config: {
        presence: {
          key: currentUser.id
        }
      }
    });

    // Track presence
    channel
      .on('presence', { event: 'sync' }, () => {
        const state = channel.presenceState();
        const users = Object.values(state).flat();
        setOnlineUsers(users);
      })
      .on('presence', { event: 'join' }, ({ newPresences }) => {
        console.log('User joined:', newPresences);
      })
      .on('presence', { event: 'leave' }, ({ leftPresences }) => {
        console.log('User left:', leftPresences);
      })
      .subscribe(async (status) => {
        if (status === 'SUBSCRIBED') {
          // Track current user
          await channel.track({
            user_id: currentUser.id,
            username: currentUser.username,
            avatar: currentUser.avatar,
            online_at: new Date().toISOString()
          });
        }
      });

    return () => {
      channel.untrack();
      supabase.removeChannel(channel);
    };
  }, [roomId, currentUser, supabase]);

  return (
    <div>
      <h3>Online Users ({onlineUsers.length})</h3>
      <ul>
        {onlineUsers.map(user => (
          <li key={user.user_id}>
            <img src={user.avatar} alt={user.username} />
            {user.username}
          </li>
        ))}
      </ul>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Typing Indicators

export default function ChatInput({ channelId, currentUser }) {
  const [typingUsers, setTypingUsers] = useState([]);
  const supabase = createClientComponentClient();
  const typingTimeoutRef = useRef(null);

  useEffect(() => {
    const channel = supabase.channel(`typing:${channelId}`);

    channel
      .on('presence', { event: 'sync' }, () => {
        const state = channel.presenceState();
        const typing = Object.values(state)
          .flat()
          .filter(user => user.user_id !== currentUser.id);
        setTypingUsers(typing);
      })
      .subscribe();

    return () => {
      supabase.removeChannel(channel);
    };
  }, [channelId, currentUser, supabase]);

  const handleTyping = async () => {
    const channel = supabase.channel(`typing:${channelId}`);

    // Track typing
    await channel.track({
      user_id: currentUser.id,
      username: currentUser.username,
      typing: true
    });

    // Clear previous timeout
    if (typingTimeoutRef.current) {
      clearTimeout(typingTimeoutRef.current);
    }

    // Stop tracking after 3 seconds
    typingTimeoutRef.current = setTimeout(async () => {
      await channel.untrack();
    }, 3000);
  };

  return (
    <div>
      {typingUsers.length > 0 && (
        <div className="text-sm text-gray-500">
          {typingUsers.map(u => u.username).join(', ')} 
          {typingUsers.length === 1 ? ' is' : ' are'} typing...
        </div>
      )}
      <input
        type="text"
        onChange={handleTyping}
        placeholder="Type a message..."
      />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Broadcast: Send Ephemeral Messages

Cursor Tracking

export default function CollaborativeCanvas({ documentId }) {
  const [cursors, setCursors] = useState({});
  const supabase = createClientComponentClient();

  useEffect(() => {
    const channel = supabase.channel(`canvas:${documentId}`);

    channel
      .on('broadcast', { event: 'cursor' }, ({ payload }) => {
        setCursors(prev => ({
          ...prev,
          [payload.user_id]: payload
        }));
      })
      .subscribe();

    return () => {
      supabase.removeChannel(channel);
    };
  }, [documentId, supabase]);

  const handleMouseMove = async (e) => {
    const channel = supabase.channel(`canvas:${documentId}`);

    await channel.send({
      type: 'broadcast',
      event: 'cursor',
      payload: {
        user_id: currentUser.id,
        x: e.clientX,
        y: e.clientY,
        username: currentUser.username
      }
    });
  };

  return (
    <div onMouseMove={handleMouseMove}>
      {/* Render other users' cursors */}
      {Object.values(cursors).map(cursor => (
        <div
          key={cursor.user_id}
          style={{
            position: 'absolute',
            left: cursor.x,
            top: cursor.y,
            pointerEvents: 'none'
          }}
        >
          <div className="cursor-pointer">
            {cursor.username}
          </div>
        </div>
      ))}

      {/* Your canvas content */}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Live Reactions

export default function LiveReactions({ postId }) {
  const [reactions, setReactions] = useState([]);
  const supabase = createClientComponentClient();

  useEffect(() => {
    const channel = supabase.channel(`reactions:${postId}`);

    channel
      .on('broadcast', { event: 'reaction' }, ({ payload }) => {
        // Add reaction with animation
        const id = Math.random();
        setReactions(prev => [...prev, { ...payload, id }]);

        // Remove after animation
        setTimeout(() => {
          setReactions(prev => prev.filter(r => r.id !== id));
        }, 3000);
      })
      .subscribe();

    return () => {
      supabase.removeChannel(channel);
    };
  }, [postId, supabase]);

  const sendReaction = async (emoji) => {
    const channel = supabase.channel(`reactions:${postId}`);

    await channel.send({
      type: 'broadcast',
      event: 'reaction',
      payload: {
        emoji,
        user_id: currentUser.id,
        timestamp: Date.now()
      }
    });
  };

  return (
    <div>
      <div className="reaction-buttons">
        {['❤️', '👍', '🎉', '🔥'].map(emoji => (
          <button key={emoji} onClick={() => sendReaction(emoji)}>
            {emoji}
          </button>
        ))}
      </div>

      <div className="reactions-overlay">
        {reactions.map(reaction => (
          <div
            key={reaction.id}
            className="floating-reaction"
            style={{
              left: `${Math.random() * 100}%`,
              animation: 'float-up 3s ease-out'
            }}
          >
            {reaction.emoji}
          </div>
        ))}
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Real-World Patterns

1. Chat Application

// components/Chat.tsx
'use client';

import { useEffect, useState, useRef } from 'react';
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs';

interface Message {
  id: string;
  content: string;
  user_id: string;
  username: string;
  created_at: string;
}

export default function Chat({ channelId, currentUser }) {
  const [messages, setMessages] = useState<Message[]>([]);
  const [newMessage, setNewMessage] = useState('');
  const [onlineUsers, setOnlineUsers] = useState([]);
  const messagesEndRef = useRef<HTMLDivElement>(null);
  const supabase = createClientComponentClient();

  useEffect(() => {
    // Fetch initial messages
    const fetchMessages = async () => {
      const { data } = await supabase
        .from('messages')
        .select('*')
        .eq('channel_id', channelId)
        .order('created_at', { ascending: true })
        .limit(50);

      setMessages(data || []);
    };

    fetchMessages();

    // Setup realtime channel
    const channel = supabase.channel(`chat:${channelId}`, {
      config: {
        presence: { key: currentUser.id }
      }
    });

    // Listen to new messages
    channel
      .on(
        'postgres_changes',
        {
          event: 'INSERT',
          schema: 'public',
          table: 'messages',
          filter: `channel_id=eq.${channelId}`
        },
        (payload) => {
          setMessages(prev => [...prev, payload.new as Message]);
          scrollToBottom();
        }
      )
      // Track online users
      .on('presence', { event: 'sync' }, () => {
        const state = channel.presenceState();
        setOnlineUsers(Object.values(state).flat());
      })
      .subscribe(async (status) => {
        if (status === 'SUBSCRIBED') {
          await channel.track({
            user_id: currentUser.id,
            username: currentUser.username,
            online_at: new Date().toISOString()
          });
        }
      });

    return () => {
      channel.untrack();
      supabase.removeChannel(channel);
    };
  }, [channelId, currentUser, supabase]);

  const scrollToBottom = () => {
    messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
  };

  const sendMessage = async (e: React.FormEvent) => {
    e.preventDefault();
    if (!newMessage.trim()) return;

    await supabase.from('messages').insert({
      content: newMessage,
      channel_id: channelId,
      user_id: currentUser.id,
      username: currentUser.username
    });

    setNewMessage('');
  };

  return (
    <div className="flex flex-col h-screen">
      {/* Online users */}
      <div className="p-4 border-b">
        <span className="text-sm text-gray-600">
          {onlineUsers.length} online
        </span>
      </div>

      {/* Messages */}
      <div className="flex-1 overflow-y-auto p-4">
        {messages.map(msg => (
          <div
            key={msg.id}
            className={`mb-4 ${
              msg.user_id === currentUser.id ? 'text-right' : ''
            }`}
          >
            <div className="text-sm text-gray-600">{msg.username}</div>
            <div
              className={`inline-block p-3 rounded-lg ${
                msg.user_id === currentUser.id
                  ? 'bg-blue-500 text-white'
                  : 'bg-gray-200'
              }`}
            >
              {msg.content}
            </div>
          </div>
        ))}
        <div ref={messagesEndRef} />
      </div>

      {/* Input */}
      <form onSubmit={sendMessage} className="p-4 border-t">
        <input
          type="text"
          value={newMessage}
          onChange={(e) => setNewMessage(e.target.value)}
          placeholder="Type a message..."
          className="w-full p-2 border rounded"
        />
      </form>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

2. Live Notifications

// components/NotificationBell.tsx
'use client';

import { useEffect, useState } from 'react';
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs';
import { Bell } from 'lucide-react';

export default function NotificationBell({ userId }) {
  const [unreadCount, setUnreadCount] = useState(0);
  const [notifications, setNotifications] = useState([]);
  const [isOpen, setIsOpen] = useState(false);
  const supabase = createClientComponentClient();

  useEffect(() => {
    // Fetch initial unread count
    const fetchUnreadCount = async () => {
      const { count } = await supabase
        .from('notifications')
        .select('*', { count: 'exact', head: true })
        .eq('user_id', userId)
        .eq('read', false);

      setUnreadCount(count || 0);
    };

    fetchUnreadCount();

    // Subscribe to new notifications
    const channel = supabase
      .channel('notifications')
      .on(
        'postgres_changes',
        {
          event: 'INSERT',
          schema: 'public',
          table: 'notifications',
          filter: `user_id=eq.${userId}`
        },
        (payload) => {
          setUnreadCount(prev => prev + 1);
          setNotifications(prev => [payload.new, ...prev]);

          // Show browser notification
          if ('Notification' in window && Notification.permission === 'granted') {
            new Notification(payload.new.title, {
              body: payload.new.message,
              icon: '/icon.png'
            });
          }
        }
      )
      .subscribe();

    return () => {
      supabase.removeChannel(channel);
    };
  }, [userId, supabase]);

  const markAsRead = async (notificationId) => {
    await supabase
      .from('notifications')
      .update({ read: true })
      .eq('id', notificationId);

    setUnreadCount(prev => Math.max(0, prev - 1));
  };

  return (
    <div className="relative">
      <button
        onClick={() => setIsOpen(!isOpen)}
        className="relative p-2"
      >
        <Bell className="w-6 h-6" />
        {unreadCount > 0 && (
          <span className="absolute top-0 right-0 bg-red-500 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center">
            {unreadCount}
          </span>
        )}
      </button>

      {isOpen && (
        <div className="absolute right-0 mt-2 w-80 bg-white rounded-lg shadow-lg border">
          <div className="p-4 border-b">
            <h3 className="font-semibold">Notifications</h3>
          </div>
          <div className="max-h-96 overflow-y-auto">
            {notifications.map(notif => (
              <div
                key={notif.id}
                className={`p-4 border-b hover:bg-gray-50 cursor-pointer ${
                  !notif.read ? 'bg-blue-50' : ''
                }`}
                onClick={() => markAsRead(notif.id)}
              >
                <div className="font-medium">{notif.title}</div>
                <div className="text-sm text-gray-600">{notif.message}</div>
              </div>
            ))}
          </div>
        </div>
      )}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

3. Collaborative Document Editing

// components/CollaborativeEditor.tsx
'use client';

import { useEffect, useState, useRef } from 'react';
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs';

export default function CollaborativeEditor({ documentId, currentUser }) {
  const [content, setContent] = useState('');
  const [cursors, setCursors] = useState({});
  const [selections, setSelections] = useState({});
  const editorRef = useRef<HTMLTextAreaElement>(null);
  const supabase = createClientComponentClient();

  useEffect(() => {
    // Fetch document
    const fetchDocument = async () => {
      const { data } = await supabase
        .from('documents')
        .select('content')
        .eq('id', documentId)
        .single();

      setContent(data?.content || '');
    };

    fetchDocument();

    const channel = supabase.channel(`doc:${documentId}`, {
      config: {
        presence: { key: currentUser.id }
      }
    });

    channel
      // Listen to content changes
      .on(
        'postgres_changes',
        {
          event: 'UPDATE',
          schema: 'public',
          table: 'documents',
          filter: `id=eq.${documentId}`
        },
        (payload) => {
          setContent(payload.new.content);
        }
      )
      // Track user presence
      .on('presence', { event: 'sync' }, () => {
        const state = channel.presenceState();
        const users = Object.values(state).flat();

        const newCursors = {};
        const newSelections = {};

        users.forEach(user => {
          if (user.user_id !== currentUser.id) {
            newCursors[user.user_id] = user.cursor;
            newSelections[user.user_id] = user.selection;
          }
        });

        setCursors(newCursors);
        setSelections(newSelections);
      })
      // Listen to cursor movements
      .on('broadcast', { event: 'cursor' }, ({ payload }) => {
        if (payload.user_id !== currentUser.id) {
          setCursors(prev => ({
            ...prev,
            [payload.user_id]: payload.position
          }));
        }
      })
      .subscribe(async (status) => {
        if (status === 'SUBSCRIBED') {
          await channel.track({
            user_id: currentUser.id,
            username: currentUser.username,
            color: currentUser.color
          });
        }
      });

    return () => {
      channel.untrack();
      supabase.removeChannel(channel);
    };
  }, [documentId, currentUser, supabase]);

  const handleChange = async (e) => {
    const newContent = e.target.value;
    setContent(newContent);

    // Debounced save
    clearTimeout(window.saveTimeout);
    window.saveTimeout = setTimeout(async () => {
      await supabase
        .from('documents')
        .update({ content: newContent })
        .eq('id', documentId);
    }, 1000);
  };

  const handleSelectionChange = async () => {
    const channel = supabase.channel(`doc:${documentId}`);
    const start = editorRef.current?.selectionStart;
    const end = editorRef.current?.selectionEnd;

    await channel.send({
      type: 'broadcast',
      event: 'cursor',
      payload: {
        user_id: currentUser.id,
        position: start,
        selection: { start, end }
      }
    });
  };

  return (
    <div className="relative">
      <textarea
        ref={editorRef}
        value={content}
        onChange={handleChange}
        onSelect={handleSelectionChange}
        className="w-full h-screen p-4 font-mono"
      />

      {/* Show other users' cursors */}
      {Object.entries(cursors).map(([userId, position]) => (
        <div
          key={userId}
          className="absolute w-0.5 h-6 bg-blue-500"
          style={{
            top: `${Math.floor(position / 80) * 24}px`,
            left: `${(position % 80) * 9}px`
          }}
        />
      ))}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Performance Optimization

1. Channel Multiplexing

// Reuse the same channel for multiple subscriptions
const channel = supabase.channel('room-1');

channel
  .on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'messages' }, handleMessage)
  .on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'reactions' }, handleReaction)
  .on('presence', { event: 'sync' }, handlePresence)
  .on('broadcast', { event: 'cursor' }, handleCursor)
  .subscribe();
Enter fullscreen mode Exit fullscreen mode

2. Throttling Updates

import { throttle } from 'lodash';

const sendCursorUpdate = throttle(async (x, y) => {
  await channel.send({
    type: 'broadcast',
    event: 'cursor',
    payload: { x, y }
  });
}, 100); // Max 10 updates per second
Enter fullscreen mode Exit fullscreen mode

3. Cleanup and Memory Management

useEffect(() => {
  const channel = supabase.channel('my-channel');

  // ... setup subscriptions

  return () => {
    // Always cleanup
    channel.untrack();
    supabase.removeChannel(channel);
  };
}, [dependencies]);
Enter fullscreen mode Exit fullscreen mode

Troubleshooting

Common Issues

  1. Not receiving updates

    • Check RLS policies
    • Verify table has REPLICA IDENTITY FULL
    • Ensure channel is subscribed
  2. Too many connections

    • Reuse channels when possible
    • Implement connection pooling
    • Use channel multiplexing
  3. Delayed updates

    • Check network latency
    • Optimize database queries
    • Use broadcast for ephemeral data

Best Practices

  1. Always cleanup subscriptions in useEffect return
  2. Use RLS policies to secure realtime data
  3. Throttle frequent updates (cursor movements, typing)
  4. Handle reconnection gracefully
  5. Test with multiple clients to catch race conditions
  6. Monitor channel status and show connection state to users

Conclusion

Supabase Realtime makes building live, collaborative features straightforward. Key takeaways:

  • Use Postgres Changes for database updates
  • Use Presence for user state tracking
  • Use Broadcast for ephemeral messages
  • Always implement proper RLS policies
  • Clean up subscriptions to prevent memory leaks

Related Guides


Last updated: February 19, 2026


Originally published at https://www.iloveblogs.blog

Top comments (0)