| name | supabase-realtime |
| description | Build realtime components with Supabase Postgres Changes in this app. Use when:
- Creating live-updating lists, dashboards, or status displays
- Adding realtime subscriptions to database tables
- Building components that need instant updates without polling
- Asked to "make this update in realtime" or "show live updates"
This skill covers the full stack: database migration, browser client setup,
realtime hook creation, and component integration.
|
Supabase Realtime Components
This app uses local React state + Supabase Realtime for live-updating components instead of React Query cache invalidation.
Why Local State Instead of React Query?
React Query is optimized for request/response patterns. For WebSocket-driven updates:
invalidate() and setData() don't reliably trigger re-renders
- Cache timing issues cause race conditions
- HMR interrupts the update cycle
Local state that you control directly is more reliable for realtime data.
Architecture Overview
┌─────────────────────────────────────────────────────────────────┐
│ Component │
│ const { items, isLoading } = useRealtimeItems({ limit: 10 }) │
└──────────────────────────────┬──────────────────────────────────┘
│
┌──────────────────────────────▼──────────────────────────────────┐
│ useRealtimeItems Hook │
│ ┌─────────────────┐ ┌─────────────────────────────────────┐ │
│ │ Local State │ │ Supabase Realtime Channel │ │
│ │ useState<T[]>() │◄───│ .on('postgres_changes', callback) │ │
│ └─────────────────┘ └─────────────────────────────────────┘ │
└──────────────────────────────┬──────────────────────────────────┘
│
┌──────────────────────────────▼──────────────────────────────────┐
│ Supabase Browser Client │
│ lib/supabase/client.ts (singleton for consistent channels) │
└──────────────────────────────┬──────────────────────────────────┘
│ WebSocket
┌──────────────────────────────▼──────────────────────────────────┐
│ Supabase Realtime │
│ Publication: supabase_realtime │
│ Table must be added via migration │
└─────────────────────────────────────────────────────────────────┘
Step 1: Enable Realtime on the Table
Create a migration to add your table to the realtime publication:
supabase migration new enable_realtime_your_table
ALTER PUBLICATION supabase_realtime ADD TABLE your_table;
Apply the migration:
supabase migration up
Verify the table is in the publication:
SELECT * FROM pg_publication_tables WHERE pubname = 'supabase_realtime';
Step 2: Understand the Browser Client
The app uses a singleton browser client (lib/supabase/client.ts):
import { createBrowserClient } from '@supabase/ssr'
import type { Database } from '@/lib/types/database'
let browserClient: ReturnType<typeof createBrowserClient<Database>> | null = null
export function createClient() {
if (!browserClient) {
browserClient = createBrowserClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
}
return browserClient
}
Critical: The singleton ensures channels are removed from the same client instance they were created on.
Step 3: Create the Realtime Hook
Create lib/hooks/use-realtime-{table}.ts:
'use client'
import { useState, useEffect, useRef, useCallback } from 'react'
import { createClient } from '@/lib/supabase/client'
import type { RealtimeChannel } from '@supabase/supabase-js'
import type { Database } from '@/lib/types/database'
type YourRow = Database['public']['Tables']['your_table']['Row']
interface UseRealtimeYourTableOptions {
limit?: number
}
interface UseRealtimeYourTableReturn {
items: YourRow[]
isLoading: boolean
error: Error | null
refetch: () => Promise<void>
}
export function useRealtimeYourTable(
options: UseRealtimeYourTableOptions = {}
): UseRealtimeYourTableReturn {
const { limit = 10 } = options
const [items, setItems] = useState<YourRow[]>([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
const channelRef = useRef<RealtimeChannel | null>(null)
const supabaseRef = useRef<ReturnType<typeof createClient> | null>(null)
const fetchItems = useCallback(async () => {
if (!supabaseRef.current) {
supabaseRef.current = createClient()
}
const supabase = supabaseRef.current
try {
setIsLoading(true)
setError(null)
const { data, error: fetchError } = await supabase
.from('your_table')
.select('*')
.order('created_at', { ascending: false })
.limit(limit)
if (fetchError) {
throw new Error(fetchError.message)
}
setItems(data ?? [])
} catch (err) {
setError(err instanceof Error ? err : new Error('Failed to fetch'))
} finally {
setIsLoading(false)
}
}, [limit])
useEffect(() => {
const supabase = createClient()
supabaseRef.current = supabase
fetchItems()
async function setupRealtime() {
try {
await supabase.realtime.setAuth()
if (channelRef.current) {
await channelRef.current.unsubscribe()
supabase.removeChannel(channelRef.current)
}
const channel = supabase
.channel('your-table-realtime')
.on(
'postgres_changes',
{
event: '*',
schema: 'public',
table: 'your_table',
},
(payload) => {
if (payload.eventType === 'INSERT' && payload.new) {
const newItem = payload.new as YourRow
setItems((prev) => {
const filtered = prev.filter((i) => i.id !== newItem.id)
return [newItem, ...filtered].slice(0, limit)
})
}
else if (payload.eventType === 'UPDATE' && payload.new) {
const updatedItem = payload.new as YourRow
setItems((prev) => {
const exists = prev.some((i) => i.id === updatedItem.id)
if (exists) {
return prev.map((i) =>
i.id === updatedItem.id ? updatedItem : i
)
} else {
return [updatedItem, ...prev].slice(0, limit)
}
})
}
else if (payload.eventType === 'DELETE' && payload.old) {
const deletedId = (payload.old as { id: string }).id
setItems((prev) => prev.filter((i) => i.id !== deletedId))
}
}
)
.subscribe()
channelRef.current = channel
} catch {
}
}
setupRealtime()
return () => {
if (channelRef.current && supabaseRef.current) {
const channel = channelRef.current
const client = supabaseRef.current
channelRef.current = null
channel.unsubscribe().then(() => client.removeChannel(channel))
}
}
}, [fetchItems, limit])
return { items, isLoading, error, refetch: fetchItems }
}
Step 4: Use in Component
'use client'
import { useRealtimeYourTable } from '@/lib/hooks/use-realtime-your-table'
import { Skeleton } from '@/components/ui/skeleton'
export function YourTableList() {
const { items, isLoading, error, refetch } = useRealtimeYourTable({ limit: 10 })
if (isLoading) {
return (
<div className="space-y-2">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-14" />
))}
</div>
)
}
if (error) {
return <div className="text-destructive">Error: {error.message}</div>
}
if (items.length === 0) {
return <p className="text-muted-foreground">No items yet.</p>
}
return (
<div className="space-y-2">
{items.map((item) => (
<div key={item.id} className="p-3 border rounded-lg">
{item.name} - {item.status}
</div>
))}
<button onClick={refetch}>Manual Refresh</button>
</div>
)
}
Key Patterns
1. Store Refs for Cleanup
const channelRef = useRef<RealtimeChannel | null>(null)
const supabaseRef = useRef<ReturnType<typeof createClient> | null>(null)
The cleanup function MUST use the same client instance that created the channel.
2. Deduplicate on INSERT
setItems((prev) => {
const filtered = prev.filter((i) => i.id !== newItem.id)
return [newItem, ...filtered].slice(0, limit)
})
Prevents duplicates if the same item arrives twice (common with rapid events).
3. Always Enforce Limit
return [newItem, ...filtered].slice(0, limit)
Prevents the list from growing unbounded.
4. Handle Missing Items on UPDATE
if (exists) {
return prev.map(...)
} else {
return [updatedItem, ...prev].slice(0, limit)
}
If an UPDATE arrives for an item not in the list (edge case), add it.
5. Authenticate Realtime Connection
await supabase.realtime.setAuth()
Required for RLS policies to apply to realtime subscriptions.
Event Filtering Options
event: '*'
event: 'INSERT'
event: 'UPDATE'
event: 'DELETE'
filter: 'user_id=eq.123'
filter: 'status=in.(pending,processing)'
Debugging
- Console logs: Add temporary logs in the payload handler
- Network tab: Filter by "ws" to see WebSocket traffic
- Verify publication: Run SQL query to check table is in
supabase_realtime
- Check RLS: Ensure SELECT policies allow the current user
Reference Implementation
See lib/hooks/use-realtime-jobs.ts for the production implementation used for n8n job status updates.
When to Use This vs React Query
| Pattern | Use Case |
|---|
| React Query (tRPC) | Forms, mutations, user-triggered fetches, infrequent updates |
| Local State + Realtime | Live dashboards, job status, notifications, chat, frequent updates |