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.
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
Frequently Asked Questions
Related Guides
Supabase Storage: Complete Guide to File Uploads and Management
Master Supabase Storage with this comprehensive guide. Learn file uploads, image optimization, CDN delivery, security policies, and advanced patterns for production applications.
Building Real-Time Collaboration Features with Next.js and Supabase
Complete guide to building real-time collaborative applications with Next.js and Supabase. Learn presence tracking, live cursors, collaborative editing, real-time notifications, and conflict resolution.
Optimistic UI Patterns with Next.js Server Actions and Supabase Realtime
Implement optimistic UI updates in Next.js with useOptimistic and Server Actions. Handle rollbacks, conflicts, and Supabase Realtime sync for instant-feeling interfaces.