Developer Guide

Supabase Realtime: Complete Guide to Building Live Applications

Master Supabase Realtime with this comprehensive guide. Learn Postgres Changes, Presence, Broadcast, and build real-time features like chat, notifications, and collaborative editing.

2026-02-19

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 ← ← ← ← ← ← ← ← ← ← ← ← ← ← ←

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

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
  )
);

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>
  );
}

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();

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();

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>
  );
}

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>
  );
}

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>
  );
}

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>
  );
}

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>
  );
}

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>
  );
}

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>
  );
}

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();

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

3. Cleanup and Memory Management#

useEffect(() => {
  const channel = supabase.channel('my-channel');
  
  // ... setup subscriptions
  
  return () => {
    // Always cleanup
    channel.untrack();
    supabase.removeChannel(channel);
  };
}, [dependencies]);

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

Last updated: February 19, 2026

Frequently Asked Questions

|

Have more questions? Contact us