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:
- Postgres Changes - Listen to database changes (INSERT, UPDATE, DELETE)
- Presence - Track and sync user state across clients
- 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#
-
Not receiving updates
- Check RLS policies
- Verify table has
REPLICA IDENTITY FULL - Ensure channel is subscribed
-
Too many connections
- Reuse channels when possible
- Implement connection pooling
- Use channel multiplexing
-
Delayed updates
- Check network latency
- Optimize database queries
- Use broadcast for ephemeral data
Best Practices#
- Always cleanup subscriptions in useEffect return
- Use RLS policies to secure realtime data
- Throttle frequent updates (cursor movements, typing)
- Handle reconnection gracefully
- Test with multiple clients to catch race conditions
- 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#
- Supabase Authentication & Authorization
- Building SaaS with Next.js and Supabase
- React Server Components Deep Dive
Last updated: February 19, 2026