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.
Building Real-Time Collaboration Features with Next.js and Supabase#
Real-time collaboration transforms single-user applications into dynamic, multi-user experiences. This comprehensive guide teaches you how to build collaborative features like Google Docs, Figma, or Notion using Next.js and Supabase Realtime.
Why Supabase Realtime?#
Built-in Features:
- PostgreSQL change data capture (CDC)
- Presence tracking (who's online)
- Broadcast messages (ephemeral data)
- WebSocket connections
- Automatic reconnection
Developer Benefits:
- No separate WebSocket server needed
- Works with existing database
- Type-safe subscriptions
- Simple API
- Scales automatically
Use Cases:
- Collaborative document editing
- Live chat applications
- Multiplayer games
- Real-time dashboards
- Presence indicators
- Live notifications
1. Realtime Fundamentals#
Enable Realtime#
-- Enable realtime for a table
ALTER PUBLICATION supabase_realtime ADD TABLE messages;
-- Or enable for all tables
ALTER PUBLICATION supabase_realtime ADD TABLE ALL TABLES;
Basic Subscription#
'use client'
import { createClient } from '@/lib/supabase/client'
import { useEffect, useState } from 'react'
export function RealtimeMessages() {
const [messages, setMessages] = useState<any[]>([])
const supabase = createClient()
useEffect(() => {
// Fetch initial data
supabase
.from('messages')
.select('*')
.order('created_at', { ascending: true })
.then(({ data }) => setMessages(data || []))
// Subscribe to changes
const channel = supabase
.channel('messages')
.on(
'postgres_changes',
{
event: '*',
schema: 'public',
table: 'messages',
},
(payload) => {
if (payload.eventType === 'INSERT') {
setMessages((current) => [...current, payload.new])
} else if (payload.eventType === 'UPDATE') {
setMessages((current) =>
current.map((msg) =>
msg.id === payload.new.id ? payload.new : msg
)
)
} else if (payload.eventType === 'DELETE') {
setMessages((current) =>
current.filter((msg) => msg.id !== payload.old.id)
)
}
}
)
.subscribe()
return () => {
supabase.removeChannel(channel)
}
}, [])
return (
<div>
{messages.map((message) => (
<div key={message.id}>{message.content}</div>
))}
</div>
)
}
2. Presence Tracking#
Who's Online#
'use client'
import { createClient } from '@/lib/supabase/client'
import { useEffect, useState } from 'react'
interface User {
id: string
name: string
avatar: string
online_at: string
}
export function OnlineUsers() {
const [users, setUsers] = useState<User[]>([])
const supabase = createClient()
useEffect(() => {
const channel = supabase.channel('online-users', {
config: {
presence: {
key: 'user-id',
},
},
})
channel
.on('presence', { event: 'sync' }, () => {
const state = channel.presenceState()
const onlineUsers = Object.values(state).flat() as User[]
setUsers(onlineUsers)
})
.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') {
const { data: { user } } = await supabase.auth.getUser()
if (user) {
await channel.track({
id: user.id,
name: user.user_metadata.name,
avatar: user.user_metadata.avatar_url,
online_at: new Date().toISOString(),
})
}
}
})
return () => {
channel.untrack()
supabase.removeChannel(channel)
}
}, [])
return (
<div className="flex gap-2">
{users.map((user) => (
<div key={user.id} className="flex items-center gap-2">
<img
src={user.avatar}
alt={user.name}
className="w-8 h-8 rounded-full"
/>
<span>{user.name}</span>
<span className="w-2 h-2 bg-green-500 rounded-full" />
</div>
))}
</div>
)
}
Typing Indicators#
'use client'
import { createClient } from '@/lib/supabase/client'
import { useEffect, useState } from 'react'
export function TypingIndicator({ channelId }: { channelId: string }) {
const [typingUsers, setTypingUsers] = useState<string[]>([])
const supabase = createClient()
useEffect(() => {
const channel = supabase.channel(`typing:${channelId}`)
channel
.on('broadcast', { event: 'typing' }, ({ payload }) => {
setTypingUsers((current) => {
if (payload.isTyping && !current.includes(payload.userId)) {
return [...current, payload.userId]
} else if (!payload.isTyping) {
return current.filter((id) => id !== payload.userId)
}
return current
})
})
.subscribe()
return () => {
supabase.removeChannel(channel)
}
}, [channelId])
if (typingUsers.length === 0) return null
return (
<div className="text-sm text-gray-500">
{typingUsers.length === 1
? `${typingUsers[0]} is typing...`
: `${typingUsers.length} people are typing...`}
</div>
)
}
// Usage in chat input
export function ChatInput({ channelId }: { channelId: string }) {
const [message, setMessage] = useState('')
const supabase = createClient()
const channel = supabase.channel(`typing:${channelId}`)
useEffect(() => {
channel.subscribe()
return () => {
supabase.removeChannel(channel)
}
}, [])
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
setMessage(e.target.value)
// Broadcast typing status
channel.send({
type: 'broadcast',
event: 'typing',
payload: {
userId: 'current-user-id',
isTyping: e.target.value.length > 0,
},
})
}
return <input value={message} onChange={handleChange} />
}
3. Live Cursors#
Cursor Tracking#
'use client'
import { createClient } from '@/lib/supabase/client'
import { useEffect, useState } from 'react'
interface Cursor {
userId: string
userName: string
x: number
y: number
color: string
}
export function LiveCursors() {
const [cursors, setCursors] = useState<Map<string, Cursor>>(new Map())
const supabase = createClient()
useEffect(() => {
const channel = supabase.channel('cursors')
channel
.on('broadcast', { event: 'cursor-move' }, ({ payload }) => {
setCursors((current) => {
const updated = new Map(current)
updated.set(payload.userId, payload)
return updated
})
})
.subscribe()
// Track own cursor
function handleMouseMove(e: MouseEvent) {
channel.send({
type: 'broadcast',
event: 'cursor-move',
payload: {
userId: 'current-user-id',
userName: 'Current User',
x: e.clientX,
y: e.clientY,
color: '#3b82f6',
},
})
}
window.addEventListener('mousemove', handleMouseMove)
return () => {
window.removeEventListener('mousemove', handleMouseMove)
supabase.removeChannel(channel)
}
}, [])
return (
<>
{Array.from(cursors.values()).map((cursor) => (
<div
key={cursor.userId}
className="fixed pointer-events-none z-50"
style={{
left: cursor.x,
top: cursor.y,
transform: 'translate(-50%, -50%)',
}}
>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill={cursor.color}
>
<path d="M5.65376 12.3673L0 0L12.3673 5.65376L7.07089 7.07089L5.65376 12.3673Z" />
</svg>
<div
className="px-2 py-1 text-xs text-white rounded"
style={{ backgroundColor: cursor.color }}
>
{cursor.userName}
</div>
</div>
))}
</>
)
}
4. Collaborative Text Editing#
Simple Collaborative Editor#
'use client'
import { createClient } from '@/lib/supabase/client'
import { useEffect, useState, useRef } from 'react'
export function CollaborativeEditor({ documentId }: { documentId: string }) {
const [content, setContent] = useState('')
const [version, setVersion] = useState(0)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const supabase = createClient()
useEffect(() => {
// Load initial content
supabase
.from('documents')
.select('content, version')
.eq('id', documentId)
.single()
.then(({ data }) => {
if (data) {
setContent(data.content)
setVersion(data.version)
}
})
// Subscribe to changes
const channel = supabase
.channel(`document:${documentId}`)
.on(
'postgres_changes',
{
event: 'UPDATE',
schema: 'public',
table: 'documents',
filter: `id=eq.${documentId}`,
},
(payload) => {
const newData = payload.new as any
// Only update if version is newer
if (newData.version > version) {
setContent(newData.content)
setVersion(newData.version)
}
}
)
.subscribe()
return () => {
supabase.removeChannel(channel)
}
}, [documentId, version])
async function handleChange(e: React.ChangeEvent<HTMLTextAreaElement>) {
const newContent = e.target.value
setContent(newContent)
// Debounced save
await supabase
.from('documents')
.update({
content: newContent,
version: version + 1,
updated_at: new Date().toISOString(),
})
.eq('id', documentId)
.eq('version', version) // Optimistic locking
setVersion(version + 1)
}
return (
<textarea
ref={textareaRef}
value={content}
onChange={handleChange}
className="w-full h-96 p-4 border rounded"
/>
)
}
Operational Transformation (OT)#
// lib/ot.ts
export interface Operation {
type: 'insert' | 'delete'
position: number
text?: string
length?: number
}
export function applyOperation(content: string, op: Operation): string {
if (op.type === 'insert') {
return (
content.slice(0, op.position) +
op.text +
content.slice(op.position)
)
} else if (op.type === 'delete') {
return (
content.slice(0, op.position) +
content.slice(op.position + (op.length || 0))
)
}
return content
}
export function transformOperation(
op1: Operation,
op2: Operation
): Operation {
// Transform op1 against op2
if (op1.type === 'insert' && op2.type === 'insert') {
if (op1.position < op2.position) {
return op1
} else {
return { ...op1, position: op1.position + (op2.text?.length || 0) }
}
}
if (op1.type === 'delete' && op2.type === 'insert') {
if (op1.position < op2.position) {
return op1
} else {
return { ...op1, position: op1.position + (op2.text?.length || 0) }
}
}
// Add more transformation rules...
return op1
}
5. Real-Time Notifications#
Notification System#
'use client'
import { createClient } from '@/lib/supabase/client'
import { useEffect, useState } from 'react'
interface Notification {
id: string
type: string
title: string
message: string
read: boolean
created_at: string
}
export function NotificationCenter() {
const [notifications, setNotifications] = useState<Notification[]>([])
const [unreadCount, setUnreadCount] = useState(0)
const supabase = createClient()
useEffect(() => {
const { data: { user } } = await supabase.auth.getUser()
if (!user) return
// Load initial notifications
supabase
.from('notifications')
.select('*')
.eq('user_id', user.id)
.order('created_at', { ascending: false })
.limit(20)
.then(({ data }) => {
if (data) {
setNotifications(data)
setUnreadCount(data.filter((n) => !n.read).length)
}
})
// Subscribe to new notifications
const channel = supabase
.channel('notifications')
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'notifications',
filter: `user_id=eq.${user.id}`,
},
(payload) => {
const newNotification = payload.new as Notification
setNotifications((current) => [newNotification, ...current])
setUnreadCount((count) => count + 1)
// Show browser notification
if ('Notification' in window && Notification.permission === 'granted') {
new Notification(newNotification.title, {
body: newNotification.message,
icon: '/logo.png',
})
}
}
)
.subscribe()
return () => {
supabase.removeChannel(channel)
}
}, [])
async function markAsRead(id: string) {
await supabase
.from('notifications')
.update({ read: true })
.eq('id', id)
setNotifications((current) =>
current.map((n) => (n.id === id ? { ...n, read: true } : n))
)
setUnreadCount((count) => Math.max(0, count - 1))
}
return (
<div className="relative">
<button className="relative">
<BellIcon />
{unreadCount > 0 && (
<span className="absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center">
{unreadCount}
</span>
)}
</button>
<div className="absolute right-0 mt-2 w-80 bg-white shadow-lg rounded-lg">
{notifications.map((notification) => (
<div
key={notification.id}
className={`p-4 border-b ${!notification.read ? 'bg-blue-50' : ''}`}
onClick={() => markAsRead(notification.id)}
>
<h4 className="font-semibold">{notification.title}</h4>
<p className="text-sm text-gray-600">{notification.message}</p>
<span className="text-xs text-gray-400">
{new Date(notification.created_at).toLocaleString()}
</span>
</div>
))}
</div>
</div>
)
}
6. Live Chat Application#
Chat Room#
'use client'
import { createClient } from '@/lib/supabase/client'
import { useEffect, useState, useRef } from 'react'
interface Message {
id: string
user_id: string
user_name: string
content: string
created_at: string
}
export function ChatRoom({ roomId }: { roomId: string }) {
const [messages, setMessages] = useState<Message[]>([])
const [newMessage, setNewMessage] = useState('')
const messagesEndRef = useRef<HTMLDivElement>(null)
const supabase = createClient()
useEffect(() => {
// Load initial messages
supabase
.from('messages')
.select('*')
.eq('room_id', roomId)
.order('created_at', { ascending: true })
.then(({ data }) => setMessages(data || []))
// Subscribe to new messages
const channel = supabase
.channel(`room:${roomId}`)
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'messages',
filter: `room_id=eq.${roomId}`,
},
(payload) => {
setMessages((current) => [...current, payload.new as Message])
}
)
.subscribe()
return () => {
supabase.removeChannel(channel)
}
}, [roomId])
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
async function sendMessage(e: React.FormEvent) {
e.preventDefault()
if (!newMessage.trim()) return
const { data: { user } } = await supabase.auth.getUser()
if (!user) return
await supabase.from('messages').insert({
room_id: roomId,
user_id: user.id,
user_name: user.user_metadata.name,
content: newMessage,
})
setNewMessage('')
}
return (
<div className="flex flex-col h-screen">
<div className="flex-1 overflow-y-auto p-4">
{messages.map((message) => (
<div key={message.id} className="mb-4">
<div className="flex items-center gap-2">
<span className="font-semibold">{message.user_name}</span>
<span className="text-xs text-gray-500">
{new Date(message.created_at).toLocaleTimeString()}
</span>
</div>
<p className="text-gray-700">{message.content}</p>
</div>
))}
<div ref={messagesEndRef} />
</div>
<form onSubmit={sendMessage} className="p-4 border-t">
<div className="flex gap-2">
<input
type="text"
value={newMessage}
onChange={(e) => setNewMessage(e.target.value)}
placeholder="Type a message..."
className="flex-1 px-4 py-2 border rounded"
/>
<button
type="submit"
className="px-6 py-2 bg-blue-500 text-white rounded"
>
Send
</button>
</div>
</form>
</div>
)
}
7. Conflict Resolution#
Last Write Wins#
async function saveDocument(documentId: string, content: string) {
const { data, error } = await supabase
.from('documents')
.update({
content,
updated_at: new Date().toISOString(),
})
.eq('id', documentId)
return { data, error }
}
Optimistic Locking#
async function saveDocumentWithLocking(
documentId: string,
content: string,
currentVersion: number
) {
const { data, error } = await supabase
.from('documents')
.update({
content,
version: currentVersion + 1,
updated_at: new Date().toISOString(),
})
.eq('id', documentId)
.eq('version', currentVersion) // Only update if version matches
if (error || !data) {
// Version conflict - reload and retry
const { data: latest } = await supabase
.from('documents')
.select('*')
.eq('id', documentId)
.single()
return { conflict: true, latest }
}
return { data, conflict: false }
}
8. Performance Optimization#
Throttle Updates#
import { useCallback, useRef } from 'react'
function useThrottle(callback: Function, delay: number) {
const lastRun = useRef(Date.now())
return useCallback(
(...args: any[]) => {
const now = Date.now()
if (now - lastRun.current >= delay) {
callback(...args)
lastRun.current = now
}
},
[callback, delay]
)
}
// Usage
const throttledUpdate = useThrottle((content: string) => {
supabase
.from('documents')
.update({ content })
.eq('id', documentId)
}, 1000) // Update at most once per second
Batch Operations#
const pendingOperations = useRef<Operation[]>([])
function queueOperation(op: Operation) {
pendingOperations.current.push(op)
}
useEffect(() => {
const interval = setInterval(async () => {
if (pendingOperations.current.length > 0) {
const ops = [...pendingOperations.current]
pendingOperations.current = []
await supabase
.from('operations')
.insert(ops.map(op => ({
document_id: documentId,
operation: op,
})))
}
}, 500)
return () => clearInterval(interval)
}, [])
9. Best Practices#
Connection Management#
- Reuse channels when possible
- Clean up subscriptions on unmount
- Handle reconnection gracefully
- Monitor connection status
Data Synchronization#
- Load initial state before subscribing
- Handle race conditions
- Implement conflict resolution
- Use optimistic updates
Security#
- Enable RLS on all tables
- Validate user permissions
- Filter subscriptions by user
- Sanitize broadcast messages
10. Troubleshooting#
Messages Not Appearing#
// Check if realtime is enabled
ALTER PUBLICATION supabase_realtime ADD TABLE messages;
// Verify RLS policies allow SELECT
CREATE POLICY "Users can view messages"
ON messages FOR SELECT
USING (true);
High Latency#
- Use broadcast for ephemeral data
- Throttle frequent updates
- Batch operations
- Optimize database queries
FAQ#
What is Supabase Realtime?#
Supabase Realtime is a WebSocket-based system that enables real-time features in your applications. It supports three types of real-time functionality: database changes (CDC), presence tracking (who's online), and broadcast messages (ephemeral data like cursor positions).
How does Supabase Realtime compare to Socket.io?#
Supabase Realtime is built on Phoenix Channels and integrates directly with your PostgreSQL database. Unlike Socket.io, you don't need to manage a separate WebSocket server. Realtime automatically syncs database changes and provides presence tracking out of the box.
Is Supabase Realtime included in the free tier?#
Yes! The free tier includes 200 concurrent connections and 2GB of bandwidth per month. For most small to medium applications, this is sufficient. Paid plans offer more connections and bandwidth.
How do I enable Realtime for my tables?#
Run ALTER PUBLICATION supabase_realtime ADD TABLE your_table_name; in the SQL editor. This enables change data capture (CDC) for that table. Remember to also set up appropriate RLS policies.
Can I use Realtime with Server Components?#
No, Realtime subscriptions require client-side JavaScript to maintain WebSocket connections. Use Client Components ('use client') for any component that subscribes to real-time updates.
How do I handle users going offline?#
Supabase Realtime automatically detects disconnections. Use presence tracking with channel.on('presence', { event: 'leave' }) to detect when users go offline. Implement heartbeat mechanisms for critical applications.
What's the latency for real-time updates?#
Typical latency is 50-200ms depending on geographic distance and network conditions. Database changes propagate through PostgreSQL's logical replication, then through the Realtime server to clients.
How do I prevent memory leaks with subscriptions?#
Always unsubscribe when components unmount. In React, return a cleanup function from useEffect that calls supabase.removeChannel(channel). This prevents memory leaks and duplicate subscriptions.
Can I filter real-time updates on the server?#
Yes! Use the filter parameter when subscribing: filter: 'user_id=eq.123'. This reduces bandwidth by only sending relevant updates to each client.
How do I handle conflicts in collaborative editing?#
Implement operational transformation (OT) or conflict-free replicated data types (CRDTs). For simpler cases, use optimistic locking with version numbers or last-write-wins with timestamps.
Is Realtime secure?#
Yes, when combined with RLS policies. Clients can only subscribe to data they have SELECT permission for. Always enable RLS and create appropriate policies to protect your data.
Can I broadcast custom events?#
Absolutely! Use channel.send({ type: 'broadcast', event: 'custom-event', payload: data }) to send ephemeral messages that don't touch the database. Perfect for cursor positions, typing indicators, and temporary state.
Frequently Asked Questions (FAQ)#
What is Supabase Realtime?#
Supabase Realtime is a WebSocket-based system that enables real-time data synchronization in your applications. It provides three main features: database change data capture (CDC), presence tracking (who's online), and broadcast messages (ephemeral data sharing).
How does Supabase Realtime work with Next.js?#
Supabase Realtime works seamlessly with Next.js through the Supabase JavaScript client. Subscribe to database changes, presence, or broadcast channels in your Client Components using useEffect hooks. The client automatically manages WebSocket connections and reconnection.
Do I need to enable Realtime for my tables?#
Yes, you must explicitly enable Realtime for tables you want to subscribe to: ALTER PUBLICATION supabase_realtime ADD TABLE your_table;. This is a security feature to prevent accidental exposure of sensitive data.
What's the difference between Postgres Changes, Presence, and Broadcast?#
Postgres Changes listen to database INSERT/UPDATE/DELETE events. Presence tracks who's currently online/active in a channel. Broadcast sends ephemeral messages between clients without storing in the database (perfect for typing indicators, cursor positions).
How many concurrent connections does Supabase Realtime support?#
Supabase Realtime supports thousands of concurrent connections. The free tier supports up to 200 concurrent connections, Pro tier supports 500, and Enterprise can scale to millions with custom infrastructure.
Can I filter Realtime subscriptions?#
Yes, use filters to only receive relevant changes: filter: 'user_id=eq.123'. This reduces bandwidth and improves performance by only sending data the client needs. Filters work with Postgres Changes subscriptions.
How do I handle conflicts in collaborative editing?#
Implement conflict resolution strategies like Last Write Wins (simplest), Optimistic Locking (version-based), or Operational Transformation (complex but robust). For most applications, optimistic locking with version numbers works well.
Does Supabase Realtime work with Row Level Security?#
Yes, Realtime respects RLS policies. Clients only receive updates for rows they have SELECT permission on. This ensures data security even in real-time scenarios. Always enable RLS on tables with Realtime.
How do I clean up Realtime subscriptions?#
Always unsubscribe when components unmount: return () => { supabase.removeChannel(channel) } in your useEffect cleanup function. This prevents memory leaks and duplicate subscriptions.
Can I use Realtime for chat applications?#
Absolutely! Supabase Realtime is perfect for chat apps. Use Postgres Changes to sync messages, Presence to show online users, and Broadcast for typing indicators. Many production chat apps use this architecture.
What's the latency for Realtime updates?#
Supabase Realtime typically delivers updates in 50-200ms depending on geographic distance and network conditions. Updates are sent via WebSocket which is much faster than polling.
How do I implement live cursors?#
Use Broadcast channels to send cursor positions: channel.send({ type: 'broadcast', event: 'cursor-move', payload: { x, y, userId } }). Throttle updates to 50-100ms to avoid overwhelming the network.
Can I use Realtime in Server Components?#
No, Realtime requires WebSocket connections which only work in the browser. Use Realtime in Client Components (with 'use client' directive). Server Components can fetch initial data, then Client Components handle real-time updates.
How much does Supabase Realtime cost?#
Realtime is included in all Supabase plans. Free tier includes 200 concurrent connections and 2GB bandwidth. Pro tier includes 500 connections and 50GB bandwidth. Additional usage is billed at $10 per 1000 concurrent connections.
Conclusion#
Supabase Realtime enables powerful collaborative features without managing WebSocket infrastructure. Start with simple presence tracking, then add live cursors, collaborative editing, and real-time notifications as needed.
Focus on user experience, handle conflicts gracefully, and optimize for performance. With these patterns, you can build collaborative applications that rival industry leaders.