Expert guide for real-time features using Supabase Realtime, WebSockets, live updates, presence, and collaborative features. Use when building chat, live updates, or collaborative apps.
Installation
Details
Usage
After installing, this skill will be available to your AI coding assistant.
Verify installation:
npx agent-skills-cli listSkill Instructions
name: real-time-features description: Expert guide for real-time features using Supabase Realtime, WebSockets, live updates, presence, and collaborative features. Use when building chat, live updates, or collaborative apps.
Real-Time Features Skill
Overview
This skill helps you implement real-time features in your Next.js application. From live data updates to collaborative editing, this covers everything you need for interactive, real-time experiences.
Supabase Realtime
Setup
// lib/supabase/client.ts
import { createBrowserClient } from '@supabase/ssr'
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
}
Real-Time Subscriptions
Subscribe to Table Changes:
'use client'
import { useEffect, useState } from 'react'
import { createClient } from '@/lib/supabase/client'
export function RealtimeMessages() {
const [messages, setMessages] = useState<Message[]>([])
const supabase = createClient()
useEffect(() => {
// Fetch initial data
supabase
.from('messages')
.select('*')
.order('created_at', { ascending: true })
.then(({ data }) => setMessages(data || []))
// Subscribe to new messages
const channel = supabase
.channel('messages')
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'messages',
},
(payload) => {
setMessages((prev) => [...prev, payload.new as Message])
}
)
.subscribe()
return () => {
supabase.removeChannel(channel)
}
}, [supabase])
return (
<div>
{messages.map((msg) => (
<div key={msg.id}>{msg.content}</div>
))}
</div>
)
}
Subscribe to All Events:
const channel = supabase
.channel('messages-all')
.on(
'postgres_changes',
{
event: '*', // INSERT, UPDATE, DELETE
schema: 'public',
table: 'messages',
},
(payload) => {
if (payload.eventType === 'INSERT') {
setMessages((prev) => [...prev, payload.new as Message])
}
if (payload.eventType === 'UPDATE') {
setMessages((prev) =>
prev.map((msg) =>
msg.id === payload.new.id ? (payload.new as Message) : msg
)
)
}
if (payload.eventType === 'DELETE') {
setMessages((prev) =>
prev.filter((msg) => msg.id !== payload.old.id)
)
}
}
)
.subscribe()
Filter Subscriptions:
// Only listen to messages in a specific room
const channel = supabase
.channel(`room:${roomId}`)
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'messages',
filter: `room_id=eq.${roomId}`,
},
(payload) => {
setMessages((prev) => [...prev, payload.new as Message])
}
)
.subscribe()
Custom Realtime Hook
// hooks/use-realtime-subscription.ts
'use client'
import { useEffect, useState } from 'react'
import { createClient } from '@/lib/supabase/client'
import type { RealtimeChannel } from '@supabase/supabase-js'
type UseRealtimeOptions<T> = {
table: string
filter?: string
event?: 'INSERT' | 'UPDATE' | 'DELETE' | '*'
onInsert?: (record: T) => void
onUpdate?: (record: T) => void
onDelete?: (record: T) => void
}
export function useRealtimeSubscription<T>({
table,
filter,
event = '*',
onInsert,
onUpdate,
onDelete,
}: UseRealtimeOptions<T>) {
const supabase = createClient()
useEffect(() => {
const channel = supabase
.channel(`${table}-changes`)
.on(
'postgres_changes',
{
event,
schema: 'public',
table,
filter,
},
(payload) => {
if (payload.eventType === 'INSERT' && onInsert) {
onInsert(payload.new as T)
}
if (payload.eventType === 'UPDATE' && onUpdate) {
onUpdate(payload.new as T)
}
if (payload.eventType === 'DELETE' && onDelete) {
onDelete(payload.old as T)
}
}
)
.subscribe()
return () => {
supabase.removeChannel(channel)
}
}, [table, filter, event, onInsert, onUpdate, onDelete, supabase])
}
// Usage
function Chat({ roomId }: { roomId: string }) {
const [messages, setMessages] = useState<Message[]>([])
useRealtimeSubscription<Message>({
table: 'messages',
filter: `room_id=eq.${roomId}`,
event: '*',
onInsert: (msg) => setMessages((prev) => [...prev, msg]),
onUpdate: (msg) =>
setMessages((prev) =>
prev.map((m) => (m.id === msg.id ? msg : m))
),
onDelete: (msg) =>
setMessages((prev) => prev.filter((m) => m.id !== msg.id)),
})
return <div>{/* Render messages */}</div>
}
Presence (Who's Online)
Track User Presence
'use client'
import { useEffect, useState } from 'react'
import { createClient } from '@/lib/supabase/client'
type PresenceState = {
[key: string]: {
user_id: string
username: string
online_at: string
}[]
}
export function usePresence(roomId: string) {
const [onlineUsers, setOnlineUsers] = useState<PresenceState>({})
const supabase = createClient()
useEffect(() => {
const channel = supabase.channel(`room:${roomId}`)
channel
.on('presence', { event: 'sync' }, () => {
const state = channel.presenceState<PresenceState>()
setOnlineUsers(state)
})
.subscribe(async (status) => {
if (status === 'SUBSCRIBED') {
// Get current user
const {
data: { user },
} = await supabase.auth.getUser()
if (user) {
// Track presence
await channel.track({
user_id: user.id,
username: user.email,
online_at: new Date().toISOString(),
})
}
}
})
return () => {
channel.untrack()
supabase.removeChannel(channel)
}
}, [roomId, supabase])
return onlineUsers
}
// Usage
function ChatRoom({ roomId }: { roomId: string }) {
const onlineUsers = usePresence(roomId)
const count = Object.keys(onlineUsers).length
return (
<div>
<p>{count} users online</p>
{Object.values(onlineUsers).map((presences) =>
presences.map((presence) => (
<div key={presence.user_id}>{presence.username}</div>
))
)}
</div>
)
}
Broadcast (Send Custom Messages)
Real-Time Collaboration
'use client'
import { useEffect, useState } from 'react'
import { createClient } from '@/lib/supabase/client'
type CursorPosition = {
x: number
y: number
user_id: string
username: string
}
export function CollaborativeCanvas({ roomId }: { roomId: string }) {
const [cursors, setCursors] = useState<CursorPosition[]>([])
const supabase = createClient()
useEffect(() => {
const channel = supabase.channel(`canvas:${roomId}`)
channel
.on('broadcast', { event: 'cursor-move' }, ({ payload }) => {
setCursors((prev) => {
const filtered = prev.filter(
(c) => c.user_id !== payload.user_id
)
return [...filtered, payload as CursorPosition]
})
})
.subscribe()
return () => {
supabase.removeChannel(channel)
}
}, [roomId, supabase])
const handleMouseMove = async (e: React.MouseEvent) => {
const {
data: { user },
} = await supabase.auth.getUser()
if (user) {
supabase.channel(`canvas:${roomId}`).send({
type: 'broadcast',
event: 'cursor-move',
payload: {
x: e.clientX,
y: e.clientY,
user_id: user.id,
username: user.email,
},
})
}
}
return (
<div onMouseMove={handleMouseMove}>
{cursors.map((cursor) => (
<div
key={cursor.user_id}
style={{
position: 'absolute',
left: cursor.x,
top: cursor.y,
width: 10,
height: 10,
borderRadius: '50%',
backgroundColor: 'blue',
pointerEvents: 'none',
}}
>
<span>{cursor.username}</span>
</div>
))}
</div>
)
}
Live Chat Application
Chat Component
'use client'
import { useEffect, useState } from 'react'
import { createClient } from '@/lib/supabase/client'
type Message = {
id: string
content: string
user_id: string
username: string
created_at: string
}
export function LiveChat({ roomId }: { roomId: string }) {
const [messages, setMessages] = useState<Message[]>([])
const [newMessage, setNewMessage] = useState('')
const [typing, setTyping] = useState<Set<string>>(new Set())
const supabase = createClient()
useEffect(() => {
// Fetch initial messages
supabase
.from('messages')
.select('*')
.eq('room_id', roomId)
.order('created_at', { ascending: true })
.then(({ data }) => setMessages(data || []))
const channel = supabase.channel(`chat:${roomId}`)
// Listen for new messages
channel
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'messages',
filter: `room_id=eq.${roomId}`,
},
(payload) => {
setMessages((prev) => [...prev, payload.new as Message])
}
)
// Listen for typing indicators
.on('broadcast', { event: 'typing' }, ({ payload }) => {
setTyping((prev) => new Set(prev).add(payload.user_id))
setTimeout(() => {
setTyping((prev) => {
const next = new Set(prev)
next.delete(payload.user_id)
return next
})
}, 3000)
})
.subscribe()
return () => {
supabase.removeChannel(channel)
}
}, [roomId, supabase])
const sendMessage = async () => {
if (!newMessage.trim()) return
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) return
await supabase.from('messages').insert({
content: newMessage,
room_id: roomId,
user_id: user.id,
username: user.email,
})
setNewMessage('')
}
const handleTyping = async () => {
const {
data: { user },
} = await supabase.auth.getUser()
if (user) {
supabase.channel(`chat:${roomId}`).send({
type: 'broadcast',
event: 'typing',
payload: { user_id: user.id },
})
}
}
return (
<div>
<div className="h-96 overflow-y-auto">
{messages.map((msg) => (
<div key={msg.id}>
<strong>{msg.username}:</strong> {msg.content}
</div>
))}
</div>
{typing.size > 0 && (
<p className="text-sm text-gray-500">
{typing.size} {typing.size === 1 ? 'person is' : 'people are'} typing...
</p>
)}
<div className="flex gap-2">
<input
value={newMessage}
onChange={(e) => {
setNewMessage(e.target.value)
handleTyping()
}}
onKeyDown={(e) => e.key === 'Enter' && sendMessage()}
placeholder="Type a message..."
/>
<button onClick={sendMessage}>Send</button>
</div>
</div>
)
}
Live Notifications
Notification System
'use client'
import { useEffect, useState } from 'react'
import { createClient } from '@/lib/supabase/client'
type Notification = {
id: string
title: string
message: string
read: boolean
created_at: string
}
export function NotificationBell() {
const [notifications, setNotifications] = useState<Notification[]>([])
const [unreadCount, setUnreadCount] = useState(0)
const supabase = createClient()
useEffect(() => {
const fetchNotifications = async () => {
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) return
const { data } = await supabase
.from('notifications')
.select('*')
.eq('user_id', user.id)
.order('created_at', { ascending: false })
setNotifications(data || [])
setUnreadCount(data?.filter((n) => !n.read).length || 0)
}
fetchNotifications()
const channel = supabase
.channel('notifications')
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'notifications',
},
(payload) => {
setNotifications((prev) => [payload.new as Notification, ...prev])
setUnreadCount((prev) => prev + 1)
// Show browser notification
if ('Notification' in window && Notification.permission === 'granted') {
new Notification(payload.new.title, {
body: payload.new.message,
})
}
}
)
.subscribe()
return () => {
supabase.removeChannel(channel)
}
}, [supabase])
const markAsRead = async (id: string) => {
await supabase
.from('notifications')
.update({ read: true })
.eq('id', id)
setNotifications((prev) =>
prev.map((n) => (n.id === id ? { ...n, read: true } : n))
)
setUnreadCount((prev) => prev - 1)
}
return (
<div>
<button className="relative">
🔔
{unreadCount > 0 && (
<span className="absolute -top-1 -right-1 bg-red-500 text-white rounded-full w-5 h-5 text-xs">
{unreadCount}
</span>
)}
</button>
<div>
{notifications.map((notification) => (
<div
key={notification.id}
className={notification.read ? 'opacity-50' : ''}
onClick={() => markAsRead(notification.id)}
>
<h4>{notification.title}</h4>
<p>{notification.message}</p>
</div>
))}
</div>
</div>
)
}
Live Dashboard Updates
Real-Time Analytics
'use client'
import { useEffect, useState } from 'react'
import { createClient } from '@/lib/supabase/client'
type Stats = {
total_users: number
active_sessions: number
revenue_today: number
}
export function LiveDashboard() {
const [stats, setStats] = useState<Stats>({
total_users: 0,
active_sessions: 0,
revenue_today: 0,
})
const supabase = createClient()
useEffect(() => {
// Fetch initial stats
const fetchStats = async () => {
const { data } = await supabase.rpc('get_dashboard_stats')
setStats(data)
}
fetchStats()
// Listen for relevant table changes
const channel = supabase
.channel('dashboard-updates')
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'users',
},
() => {
setStats((prev) => ({
...prev,
total_users: prev.total_users + 1,
}))
}
)
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'orders',
},
(payload) => {
setStats((prev) => ({
...prev,
revenue_today: prev.revenue_today + payload.new.amount,
}))
}
)
.subscribe()
return () => {
supabase.removeChannel(channel)
}
}, [supabase])
return (
<div className="grid grid-cols-3 gap-4">
<div className="p-4 bg-white rounded shadow">
<h3>Total Users</h3>
<p className="text-3xl font-bold">{stats.total_users}</p>
</div>
<div className="p-4 bg-white rounded shadow">
<h3>Active Sessions</h3>
<p className="text-3xl font-bold">{stats.active_sessions}</p>
</div>
<div className="p-4 bg-white rounded shadow">
<h3>Revenue Today</h3>
<p className="text-3xl font-bold">${stats.revenue_today}</p>
</div>
</div>
)
}
Optimistic Updates
Instant UI Updates
'use client'
import { useState } from 'react'
import { createClient } from '@/lib/supabase/client'
export function TodoList() {
const [todos, setTodos] = useState<Todo[]>([])
const supabase = createClient()
const addTodo = async (text: string) => {
// Optimistic update - update UI immediately
const tempId = crypto.randomUUID()
const optimisticTodo = {
id: tempId,
text,
done: false,
created_at: new Date().toISOString(),
}
setTodos((prev) => [...prev, optimisticTodo])
try {
// Insert to database
const { data, error } = await supabase
.from('todos')
.insert({ text, done: false })
.select()
.single()
if (error) throw error
// Replace temp todo with real one
setTodos((prev) =>
prev.map((todo) => (todo.id === tempId ? data : todo))
)
} catch (error) {
// Rollback on error
setTodos((prev) => prev.filter((todo) => todo.id !== tempId))
console.error('Failed to add todo:', error)
}
}
return <div>{/* Render todos */}</div>
}
Connection Status
Track Connection State
'use client'
import { useEffect, useState } from 'react'
import { createClient } from '@/lib/supabase/client'
export function ConnectionStatus() {
const [isConnected, setIsConnected] = useState(true)
const supabase = createClient()
useEffect(() => {
const channel = supabase.channel('connection-test')
channel
.on('system', { event: 'online' }, () => setIsConnected(true))
.on('system', { event: 'offline' }, () => setIsConnected(false))
.subscribe()
return () => {
supabase.removeChannel(channel)
}
}, [supabase])
if (!isConnected) {
return (
<div className="bg-red-500 text-white p-2 text-center">
⚠️ Connection lost. Reconnecting...
</div>
)
}
return null
}
Best Practices Checklist
- Clean up subscriptions on unmount
- Handle connection errors gracefully
- Implement optimistic updates for better UX
- Use presence for online status
- Implement typing indicators in chat
- Show connection status to users
- Rate limit broadcast messages
- Use filters to reduce unnecessary updates
- Implement reconnection logic
- Handle duplicate messages
- Use channels efficiently
- Test with slow/offline connections
- Implement message queuing for offline
- Monitor realtime usage/costs
When to Use This Skill
Invoke this skill when:
- Building chat applications
- Implementing live notifications
- Creating collaborative features
- Adding presence/online status
- Building real-time dashboards
- Implementing live updates
- Creating multiplayer features
- Debugging realtime connections
- Optimizing realtime performance
- Implementing typing indicators
More by eddiebe147
View allexit: Exit engine for ID8Labs. Position for and execute successful exits through strategic preparation, valuation optimization, and deal execution.
Expert guide for deploying Next.js apps to Vercel, managing environments, CI/CD pipelines, and production best practices. Use when deploying, setting up automation, or managing production.
Expert guide for error handling, logging, monitoring, and debugging. Use when implementing error boundaries, logging systems, or integrating monitoring tools like Sentry.
tracker: Portfolio-level project tracker for ID8Labs. Tracks all projects through lifecycle states, enforces stage gates, calculates decay, and coordinates review rituals. The nervous system of the ID8Labs pipeline.
