skills/ballee/supabase-realtime-specialist/SKILL.md
Implement Supabase Realtime subscriptions for live data updates; use when building real-time features like live notifications, collaborative editing, presence detection, or live data feeds
npx skillsauth add javeedishaq/ai-workflow-orchestrator supabase-realtime-specialistInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
| Environment | Project ID | URL |
|-------------|-----------|-----|
| Production | csjruhqyqzzqxnfeyiaf | https://csjruhqyqzzqxnfeyiaf.supabase.co |
| Staging | hxpcknyqswetsqmqmeep | https://hxpcknyqswetsqmqmeep.supabase.co |
For database credentials and deployment: See .claude/skills/production-database-query/SKILL.md
Supabase Realtime enables live data updates without polling. This skill covers:
-- In Supabase SQL Editor
BEGIN;
-- Realtime must be enabled on tables you want to subscribe to
ALTER PUBLICATION supabase_realtime ADD TABLE events;
ALTER PUBLICATION supabase_realtime ADD TABLE users;
COMMIT;
'use client'
import { useEffect, useState } from 'react'
import { useSupabaseClient } from '@kit/supabase/hooks/use-supabase'
export function LiveEventsList() {
const [events, setEvents] = useState<Event[]>([])
const supabase = useSupabaseClient()
useEffect(() => {
// Fetch initial data
const fetchEvents = async () => {
const { data } = await supabase
.from('events')
.select()
setEvents(data || [])
}
fetchEvents()
// Subscribe to changes
const channel = supabase
.channel('events')
.on(
'postgres_changes',
{ event: '*', schema: 'public', table: 'events' },
(payload) => {
// payload.eventType = 'INSERT' | 'UPDATE' | 'DELETE'
// payload.new = new record (INSERT, UPDATE)
// payload.old = old record (UPDATE, DELETE)
if (payload.eventType === 'INSERT') {
setEvents(prev => [payload.new as Event, ...prev])
} else if (payload.eventType === 'UPDATE') {
setEvents(prev =>
prev.map(e =>
e.id === payload.new.id ? payload.new : e
)
)
} else if (payload.eventType === 'DELETE') {
setEvents(prev => prev.filter(e => e.id !== payload.old.id))
}
}
)
.subscribe()
// Cleanup
return () => {
supabase.removeChannel(channel)
}
}, [supabase])
return (
<div>
<h2>Live Events ({events.length})</h2>
{events.map(event => (
<EventCard key={event.id} event={event} />
))}
</div>
)
}
// Subscribe to any changes on 'events' table
const channel = supabase
.channel('events')
.on(
'postgres_changes',
{
event: '*', // 'INSERT' | 'UPDATE' | 'DELETE' | '*'
schema: 'public',
table: 'events',
},
(payload) => {
console.log('Change:', payload.eventType, payload.new || payload.old)
}
)
.subscribe()
// Cleanup
supabase.removeChannel(channel)
// Send custom messages (not database changes)
const channel = supabase.channel('notifications')
// Send message
channel.send({
type: 'broadcast',
event: 'user_action',
payload: { userId: 123, action: 'liked_post' },
})
// Listen to messages
channel.on('broadcast', { event: 'user_action' }, (payload) => {
console.log('User action:', payload.payload)
})
channel.subscribe()
'use client'
import { useEffect, useState } from 'react'
import { useSupabaseClient } from '@kit/supabase/hooks/use-supabase'
import { useUser } from '@kit/supabase/hooks/use-user'
export function OnlineUsers() {
const [onlineUsers, setOnlineUsers] = useState<User[]>([])
const supabase = useSupabaseClient()
const user = useUser()
useEffect(() => {
if (!user) return
const channel = supabase.channel('presence')
channel.on('presence', { event: 'sync' }, () => {
// Get all users in channel
const users = channel.presenceState()
const userList = Object.values(users).flat() as User[]
setOnlineUsers(userList)
})
channel.on('presence', { event: 'join' }, (payload) => {
// New user joined
const newUser = payload.newPresences[0]
setOnlineUsers(prev => [...prev, newUser])
})
channel.on('presence', { event: 'leave' }, (payload) => {
// User left
const leftUser = payload.leftPresences[0]
setOnlineUsers(prev =>
prev.filter(u => u.id !== leftUser.id)
)
})
// Subscribe this user
channel.subscribe(async (status) => {
if (status === 'SUBSCRIBED') {
await channel.track({
id: user.id,
email: user.email,
lastSeen: new Date(),
})
}
})
return () => {
supabase.removeChannel(channel)
}
}, [supabase, user])
return (
<div>
<h3>Online Users ({onlineUsers.length})</h3>
{onlineUsers.map(u => (
<div key={u.id}>{u.email} 🟢 Online</div>
))}
</div>
)
}
'use client'
import { useEffect, useState } from 'react'
import { useSupabaseClient } from '@kit/supabase/hooks/use-supabase'
export function LiveFeed() {
const [posts, setPosts] = useState<Post[]>([])
const [isLoading, setIsLoading] = useState(true)
const supabase = useSupabaseClient()
useEffect(() => {
let isMounted = true
const setupRealtime = async () => {
// Fetch initial posts
const { data: initialPosts } = await supabase
.from('posts')
.select('*')
.order('created_at', { ascending: false })
.limit(50)
if (isMounted) {
setPosts(initialPosts || [])
setIsLoading(false)
}
// Subscribe to new posts
const channel = supabase
.channel('posts')
.on(
'postgres_changes',
{ event: 'INSERT', schema: 'public', table: 'posts' },
(payload) => {
if (isMounted) {
// New post inserted
setPosts(prev => [payload.new as Post, ...prev])
}
}
)
.on(
'postgres_changes',
{ event: 'UPDATE', schema: 'public', table: 'posts' },
(payload) => {
if (isMounted) {
// Post updated (likes, comments count, etc.)
setPosts(prev =>
prev.map(p => (p.id === payload.new.id ? payload.new : p))
)
}
}
)
.subscribe()
return () => {
supabase.removeChannel(channel)
}
}
setupRealtime()
return () => {
isMounted = false
}
}, [supabase])
return (
<div>
{isLoading ? (
<div>Loading posts...</div>
) : (
posts.map(post => (
<PostCard key={post.id} post={post} />
))
)}
</div>
)
}
'use client'
import { useEffect, useState } from 'react'
import { useSupabaseClient } from '@kit/supabase/hooks/use-supabase'
import { useUser } from '@kit/supabase/hooks/use-user'
export function CollaborativeEditor({ documentId }: { documentId: string }) {
const [content, setContent] = useState('')
const [remoteCursors, setRemoteCursors] = useState<Cursor[]>([])
const supabase = useSupabaseClient()
const user = useUser()
useEffect(() => {
if (!user) return
const channel = supabase
.channel(`editor-${documentId}`, {
config: { broadcast: { self: true } },
})
// Listen to document changes
channel.on(
'postgres_changes',
{
event: 'UPDATE',
schema: 'public',
table: 'documents',
filter: `id=eq.${documentId}`,
},
(payload) => {
setContent(payload.new.content)
}
)
// Listen to cursor positions
channel.on('broadcast', { event: 'cursor' }, (payload) => {
setRemoteCursors(prev => [
...prev.filter(c => c.userId !== payload.payload.userId),
payload.payload,
])
})
channel.subscribe()
return () => {
supabase.removeChannel(channel)
}
}, [supabase, documentId, user])
const handleContentChange = async (newContent: string) => {
setContent(newContent)
// Update database
await supabase
.from('documents')
.update({ content: newContent })
.eq('id', documentId)
}
const handleMouseMove = (e: MouseEvent) => {
// Broadcast cursor position
const channel = supabase.channel(`editor-${documentId}`)
channel.send({
type: 'broadcast',
event: 'cursor',
payload: {
userId: user?.id,
x: e.clientX,
y: e.clientY,
userName: user?.email,
},
})
}
return (
<div onMouseMove={handleMouseMove}>
<textarea
value={content}
onChange={e => handleContentChange(e.target.value)}
placeholder="Start typing..."
/>
{/* Show remote cursors */}
{remoteCursors.map(cursor => (
<div
key={cursor.userId}
style={{
position: 'absolute',
left: `${cursor.x}px`,
top: `${cursor.y}px`,
pointerEvents: 'none',
}}
>
<div className="text-xs bg-blue-500 text-white px-2 py-1 rounded">
{cursor.userName}
</div>
</div>
))}
</div>
)
}
'use client'
import { useEffect } from 'react'
import { useSupabaseClient } from '@kit/supabase/hooks/use-supabase'
import { useUser } from '@kit/supabase/hooks/use-user'
import { useToast } from '@kit/ui/use-toast'
export function NotificationListener() {
const supabase = useSupabaseClient()
const user = useUser()
const { toast } = useToast()
useEffect(() => {
if (!user) return
const channel = supabase
.channel(`notifications-${user.id}`)
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'notifications',
filter: `user_id=eq.${user.id}`,
},
(payload) => {
const notification = payload.new as Notification
toast({
title: notification.title,
description: notification.message,
duration: 5000,
})
}
)
.subscribe()
return () => {
supabase.removeChannel(channel)
}
}, [supabase, user, toast])
return null // This component just listens
}
By default, RLS policies do NOT apply to Realtime subscriptions!
@supabase JWT for Auth// Supabase client with JWT auth
const channel = supabase
.channel('events', {
config: {
broadcast: { ack: true },
presence: { key: user.id },
},
})
.subscribe()
-- RLS policy for Realtime (in addition to regular SELECT)
CREATE POLICY "realtime_users_can_see_own_events"
ON events
FOR SELECT
TO authenticated
USING (auth.uid() = user_id);
-- Enable realtime for this policy
ALTER POLICY "realtime_users_can_see_own_events"
ON events
USING (auth.uid() = user_id);
// Filter on client side (less efficient but safe)
const channel = supabase
.channel('events')
.on('postgres_changes', { event: '*', table: 'events' }, (payload) => {
// Only process if user has access
if (payload.new?.user_id === user?.id) {
// Process update
}
})
.subscribe()
❌ WRONG:
// Creates new subscription on every render
const { data } = useQuery(() =>
supabase.channel('events').on(...).subscribe()
)
✅ RIGHT: Use useEffect with cleanup
useEffect(() => {
const channel = supabase.channel('events').on(...).subscribe()
return () => supabase.removeChannel(channel)
}, [])
❌ WRONG:
// Multiple components create channel 'events' → conflict
const channel1 = supabase.channel('events')
const channel2 = supabase.channel('events') // Overwrites channel1
✅ RIGHT: Use unique channel names
const channel = supabase.channel(`events-${userId}`)
❌ WRONG: No cleanup
useEffect(() => {
supabase.channel('events').subscribe()
// No cleanup - subscription never stops
}, [])
✅ RIGHT: Always cleanup
useEffect(() => {
const channel = supabase.channel('events').subscribe()
return () => supabase.removeChannel(channel)
}, [])
ALTER PUBLICATION)useSupabaseClient from @kit/supabase/hooksuseEffect (not in render)Check:
Is Realtime enabled on the table?
SELECT * FROM pg_publication_tables WHERE pubname = 'supabase_realtime';
Is the user authenticated?
const { data: { user } } = await supabase.auth.getUser()
console.log('User:', user)
Is the filter correct?
// Check filter matches actual changes
event: 'INSERT', // or UPDATE, DELETE, '*'
schema: 'public', // correct schema?
table: 'events', // correct table?
Check:
const adminClient = createClient(URL, ADMIN_KEY)
// This bypasses RLS - if it works, RLS is the issue
Solution: Add filters
.on(
'postgres_changes',
{
event: 'UPDATE',
schema: 'public',
table: 'events',
filter: `user_id=eq.${userId}`, // Only this user's events
},
(payload) => { }
)
tools
# Test Patterns Testing patterns for reliable, maintainable, and fast tests. > **Template Usage:** Customize for your test framework (Vitest, Jest, Playwright, etc.) and assertion library. ## Test Structure ```typescript // user.test.ts import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { userService } from '@/services/user.service'; import { createTestUser, cleanupTestData } from '@/tests/helpers'; describe('UserService', () => { let testUserId: string; befor
tools
# State Management Patterns Client-side state management patterns for modern applications. > **Template Usage:** Customize for your state library (React Query, Zustand, Jotai, Redux, etc.). ## State Categories | Type | Description | Solution | |------|-------------|----------| | **Server State** | Data from API/database | React Query, SWR | | **Client State** | UI state, user preferences | Zustand, Jotai, useState | | **Form State** | Form inputs, validation | React Hook Form, Formik | | **U
development
# Service Patterns Service layer patterns for clean architecture with proper error handling, logging, and type safety. > **Template Usage:** Customize for your ORM (Prisma, Drizzle, TypeORM, etc.) and logging solution. ## Result Type Pattern Never throw exceptions from services. Always return a Result type. ```typescript // lib/result.ts export type Result<T, E = Error> = | { success: true; data: T } | { success: false; error: E }; export function ok<T>(data: T): Result<T, never> { r
testing
# Row-Level Security Patterns Database security patterns for multi-tenant and user-scoped data. > **Template Usage:** Customize for your database (PostgreSQL, Supabase, etc.) and auth system. ## RLS Fundamentals ### Enable RLS on Tables ```sql -- Enable RLS (required before policies take effect) ALTER TABLE users ENABLE ROW LEVEL SECURITY; ALTER TABLE posts ENABLE ROW LEVEL SECURITY; ALTER TABLE comments ENABLE ROW LEVEL SECURITY; -- Force RLS for table owners too (recommended) ALTER TABLE