1. Supabase Realtime Setup
Supabase Realtime basiert auf Phoenix Channels (Elixir) und laeuft ueber eine persistente WebSocket-Verbindung. Jede Verbindung besteht aus einem Client, beliebig vielen Channels (Themen) und Subscriptions auf Events. Claude Code kennt die aktuelle API und generiert den Boilerplate automatisch — du beschreibst, was sich aendern soll, Claude schreibt den Hook.
Das grundlegende Setup besteht aus drei Schritten: Client anlegen, Channel definieren, Events abonnieren. Der Client haelt die WebSocket-Verbindung und reconnectet automatisch.
Installation und Client-Initialisierung
Zuerst das Supabase-SDK und optional die TypeScript-Typen installieren:
# NPM npm install @supabase/supabase-js # Oder mit pnpm / bun pnpm add @supabase/supabase-js bun add @supabase/supabase-js # TypeScript-Typen aus Supabase CLI generieren (optional, aber empfohlen) npx supabase gen types typescript --project-id your-project-ref > types/supabase.ts
import { createClient } from '@supabase/supabase-js' import type { Database } from '../types/supabase' const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL! const supabaseAnon = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! export const supabase = createClient<Database>(supabaseUrl, supabaseAnon, { realtime: { params: { eventsPerSecond: 10, // Rate-Limit clientseitig }, }, })
Ersten Realtime-Channel anlegen
import { supabase } from '../lib/supabase' // Channel anlegen (Name ist frei waehlbar, muss im Projekt eindeutig sein) const channel = supabase.channel('room:global') // Subscription starten channel.subscribe((status) => { if (status === 'SUBSCRIBED') { console.log('Realtime verbunden!') } if (status === 'CHANNEL_ERROR') { console.error('Channel-Fehler — Channel-Name pruefen') } }) // Cleanup (z.B. in useEffect Return) await supabase.removeChannel(channel)
RealtimeChannel
Logische Gruppe fuer Events. Mehrere Subscriptions pro Channel moeglich.
Status-Lifecycle
SUBSCRIBED → CHANNEL_ERROR → TIMED_OUT → CLOSED. Immer auf Fehler reagieren.
Multiplexing
Eine WebSocket-Verbindung, viele Channels — effizient und skalierbar.
Auto-Reconnect
Das SDK reconnectet automatisch bei Verbindungsabbruch — kein manuelles Handling noetig.
2. Postgres Changes: INSERT, UPDATE, DELETE live empfangen
Postgres Changes ist das Kernstueck von Supabase Realtime. Es nutzt Postgres Logical Replication, um Zeilen-Aenderungen (INSERT, UPDATE, DELETE, TRUNCATE) in Echtzeit an abonnierte Clients zu pushen. Der Filter laesst sich auf Schema, Tabelle und sogar einzelne Zeilen einschraenken.
Row-Level Security (RLS) greift dabei auf Datenbankebene: Ein Client sieht nur Aenderungen an Zeilen, auf die er auch SELECT-Zugriff hat. Claude Code kann RLS-Policies automatisch mitgenerieren, wenn du die Anforderungen beschreibst.
Alle Events einer Tabelle abonnieren
const channel = supabase .channel('schema-db-changes') .on( 'postgres_changes', { event: '*', // INSERT | UPDATE | DELETE | TRUNCATE | * schema: 'public', table: 'messages', }, (payload) => { console.log('Change received!', payload) // payload.eventType: 'INSERT' | 'UPDATE' | 'DELETE' // payload.new: Neue Zeile (INSERT / UPDATE) // payload.old: Alte Zeile (UPDATE / DELETE) // payload.table, payload.schema, payload.commit_timestamp } ) .subscribe()
Einzelne Event-Typen filtern
// Nur neue Nachrichten empfangen channel.on( 'postgres_changes', { event: 'INSERT', schema: 'public', table: 'messages' }, (payload) => appendMessage(payload.new) ) // Nur Updates (z.B. Gelesen-Status) channel.on( 'postgres_changes', { event: 'UPDATE', schema: 'public', table: 'messages' }, (payload) => markRead(payload.new.id) ) // Zeilenfilter: Nur eigene Nachrichten des aktuellen Nutzers channel.on( 'postgres_changes', { event: 'INSERT', schema: 'public', table: 'messages', filter: `room_id=eq.${roomId}`, // Serverside-Filter! }, (payload) => appendMessage(payload.new) )
Row-Level Security fuer Realtime aktivieren
RLS muss explizit fuer Realtime aktiviert werden. Claude Code generiert bei Bedarf die passende SQL-Migration:
-- 1. RLS fuer Tabelle aktivieren ALTER TABLE messages ENABLE ROW LEVEL SECURITY; -- 2. Realtime fuer diese Tabelle erlauben ALTER PUBLICATION supabase_realtime ADD TABLE messages; -- 3. Lese-Policy: Nutzer sieht nur Nachrichten in seinen Raeumen CREATE POLICY "users_see_own_room_messages" ON messages FOR SELECT USING ( room_id IN ( SELECT room_id FROM room_members WHERE user_id = auth.uid() ) ); -- 4. Realtime nutzt denselben Nutzer-Kontext wie normale Queries -- (JWT aus supabase.auth.getSession() wird automatisch mitgesendet)
| Event | payload.new | payload.old | Typischer Use-Case |
|---|---|---|---|
INSERT |
Neue Zeile (vollstaendig) | {} |
Live-Feed, Chat, Notifications |
UPDATE |
Aktualisierte Zeile | Alte Werte (Primkey) | Status-Aenderungen, Likes |
DELETE |
{} |
Geloeschte Zeile (Primkey) | Item entfernen, Archivieren |
TRUNCATE |
{} |
{} |
Cache leeren, Reset |
3. Broadcast: Custom Events in Echtzeit senden
Broadcast ist ideal fuer Events, die nicht persistiert werden muessen: Cursor-Positionen, Tipp-Indikatoren, Reaktionen, Live-Kommentare. Im Gegensatz zu Postgres Changes wird nichts in die Datenbank geschrieben — die Nachricht wird nur an alle aktuell verbundenen Clients im selben Channel weitergeleitet.
Das Latenzfenster ist extrem gering (typisch <100ms weltweit) weil kein Datenbankschreibzugriff im kritischen Pfad liegt. Claude Code verwendet Broadcast standardmaessig fuer alle Cursor-Sharing- und Live-Collaboration-Features.
Events senden und empfangen
const channel = supabase.channel('room:collab-123') // Empfaenger registrieren (vor subscribe!) channel.on( 'broadcast', { event: 'cursor' }, (payload) => { renderCursor(payload.payload.userId, payload.payload.x, payload.payload.y) } ) await channel.subscribe() // Event senden (nach subscribe) const sendCursor = (x: number, y: number) => { channel.send({ type: 'broadcast', event: 'cursor', payload: { userId: currentUser.id, x, y }, }) }
Use-Case: Echtzeit-Cursor-Sharing (Figma-Style)
import { useEffect, useRef, useState } from 'react' import { supabase } from '../lib/supabase' interface CursorState { userId: string x: number y: number color: string } export function useCursorShare(roomId: string, userId: string) { const [cursors, setCursors] = useState<Record<string, CursorState>>({}) const channelRef = useRef<any>(null) const throttleRef = useRef<number>(0) useEffect(() => { const channel = supabase.channel(`cursors:${roomId}`) channelRef.current = channel channel .on('broadcast', { event: 'cursor-move' }, ({ payload }) => { if (payload.userId === userId) return // eigene ignorieren setCursors((prev) => ({ ...prev, [payload.userId]: payload })) }) .subscribe() return () => { supabase.removeChannel(channel) } }, [roomId, userId]) const trackCursor = (x: number, y: number) => { const now = Date.now() if (now - throttleRef.current < 50) return // Max 20 Events/s throttleRef.current = now channelRef.current?.send({ type: 'broadcast', event: 'cursor-move', payload: { userId, x, y, color: '#7c3aed' }, }) } return { cursors, trackCursor } }
Broadcast-Optionen: self und ack
// self: true → eigene Broadcasts auch lokal empfangen (Standard: false) const channel = supabase.channel('room:test', { config: { broadcast: { self: true, ack: false }, // ack: true → send() gibt Promise zurueck, das resolved wenn Server Event bestätigt // ack: false → fire-and-forget (Standard, niedrigere Latenz) }, })
4. Presence: Wer ist gerade online?
Presence loest ein klassisches Distributed-Systems-Problem elegant: Alle Clients sehen konsistent, welche Nutzer gerade verbunden sind — inklusive automatischem Cleanup bei Verbindungsabbruch. Supabase implementiert Presence via CRDT (Conflict-free Replicated Data Types), sodass der Zustand bei allen Clients konvergiert.
Typische Anwendungsfaelle: "X Personen sehen dieses Dokument", Tipp-Indikatoren in Chats, Multiplayer-Cursor, Collaborative-Editing-Sessions. Claude Code implementiert Presence-Features standardmaessig mit Join/Leave-Events und State-Sync.
Presence: track, sync, join, leave
interface UserPresence { userId: string username: string avatar: string joinedAt: string } const channel = supabase.channel('room:presence-demo', { config: { presence: { key: currentUser.id } }, // Eindeutiger Key pro Nutzer }) // sync: wird bei jeder State-Aenderung gefeuert (initiales Sync + join/leave) channel.on('presence', { event: 'sync' }, () => { const state = channel.presenceState<UserPresence>() // state = { [key: string]: UserPresence[] } (Array wegen Multi-Tab) const onlineUsers = Object.values(state).flat() setOnlineUsers(onlineUsers) }) // join: neuer Nutzer tritt bei channel.on('presence', { event: 'join' }, ({ key, newPresences }) => { console.log(`${newPresences[0].username} ist beigetreten`) }) // leave: Nutzer verlässt (auch bei Verbindungsabbruch!) channel.on('presence', { event: 'leave' }, ({ key, leftPresences }) => { console.log(`${leftPresences[0].username} hat verlassen`) }) await channel.subscribe() // Eigenen Zustand registrieren (nach subscribe!) await channel.track({ userId: currentUser.id, username: currentUser.name, avatar: currentUser.avatarUrl, joinedAt: new Date().toISOString(), })
Presence-State mit Claude Code aktualisieren
// Zustand aktualisieren (z.B. Aktivitaet tracken) await channel.track({ ...currentPresence, lastActivity: new Date().toISOString(), currentPage: window.location.pathname, }) // Presence beenden (Nutzer meldet sich bewusst ab) await channel.untrack() // Alle aktuell verbundenen Nutzer abrufen (synchron nach sync-Event) const state = channel.presenceState<UserPresence>() const online = Object.values(state).flat() const count = online.length
CRDT-Konsistenz
Alle Clients konvergieren zum selben State — auch nach Netzwerkproblemen.
Auto-Cleanup
Bei Verbindungsabbruch wird der Nutzer automatisch aus dem Presence-State entfernt.
Multi-Tab
Gleicher Nutzer in zwei Tabs = zwei Eintraege unter demselben Key.
Beliebige Payload
Jeder serialisierbare JSON-Wert kann als Presence-Zustand getrackt werden.
5. React-Integration: useEffect-Cleanup und Custom Hook
Der haeufigste Fehler bei Realtime + React: Kein Cleanup beim Unmount fuehrt zu Memory Leaks und doppelten Event-Handlern im StrictMode (React 18 mountet zweimal!). Das korrekte Muster ist immer: Channel anlegen, subscriben, im Cleanup-Return entfernen.
Claude Code kennt dieses Anti-Pattern und generiert Cleanup immer automatisch mit. Der
Custom Hook useLiveTable kapselt das komplette Lifecycle-Management und
kann fuer beliebige Supabase-Tabellen wiederverwendet werden.
useEffect mit Realtime — korrekte Cleanup-Strategie
import { useEffect, useState } from 'react' import { supabase } from '../lib/supabase' function LiveMessages({ roomId }: { roomId: string }) { const [messages, setMessages] = useState<Message[]>([]) useEffect(() => { // 1. Initiale Daten laden supabase .from('messages') .select('*') .eq('room_id', roomId) .order('created_at') .then(({ data }) => setMessages(data ?? [])) // 2. Realtime-Subscription const channel = supabase .channel(`messages:${roomId}`) .on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'messages', filter: `room_id=eq.${roomId}`, }, (payload) => { setMessages((prev) => [...prev, payload.new as Message]) }) .subscribe() // 3. PFLICHT: Cleanup beim Unmount / roomId-Wechsel return () => { supabase.removeChannel(channel) } }, [roomId]) // roomId in Dependency-Array — bei Wechsel neu subscriben return ( <ul> {messages.map((m) => <li key={m.id}>{m.content}</li>)} </ul> ) }
Custom Hook: useLiveTable (universell wiederverwendbar)
import { useEffect, useState, useCallback } from 'react' import { supabase } from '../lib/supabase' import type { RealtimePostgresChangesPayload } from '@supabase/supabase-js' interface UseLiveTableOptions<T> { table: string schema?: string filter?: string onInsert?: (row: T) => void onUpdate?: (row: T) => void onDelete?: (row: Partial<T>) => void initialFetch?: () => Promise<T[]> } export function useLiveTable<T extends { id: string }>( options: UseLiveTableOptions<T> ) { const { table, schema = 'public', filter, onInsert, onUpdate, onDelete, initialFetch } = options const [rows, setRows] = useState<T[]>([]) const [loading, setLoading] = useState(true) const [error, setError] = useState<Error | null>(null) useEffect(() => { let mounted = true // Initiale Daten if (initialFetch) { initialFetch() .then((data) => { if (mounted) { setRows(data); setLoading(false) } }) .catch((e) => { if (mounted) { setError(e); setLoading(false) } }) } else { setLoading(false) } const handleChange = (payload: RealtimePostgresChangesPayload<T>) => { if (!mounted) return if (payload.eventType === 'INSERT') { setRows((p) => [...p, payload.new]) onInsert?.(payload.new) } if (payload.eventType === 'UPDATE') { setRows((p) => p.map((r) => r.id === payload.new.id ? payload.new : r)) onUpdate?.(payload.new) } if (payload.eventType === 'DELETE') { setRows((p) => p.filter((r) => r.id !== (payload.old as T).id)) onDelete?.(payload.old as Partial<T>) } } const channelName = `live-${table}-${filter ?? 'all'}` const channel = supabase .channel(channelName) .on('postgres_changes', { event: '*', schema, table, filter }, handleChange) .subscribe() return () => { mounted = false supabase.removeChannel(channel) } }, [table, schema, filter]) // Callbacks absichtlich nicht in Deps (stabil halten) return { rows, loading, error } } // Verwendung: // const { rows, loading } = useLiveTable<Todo>({ // table: 'todos', // filter: `user_id=eq.${userId}`, // initialFetch: () => supabase.from('todos').select().eq('user_id', userId).then(r => r.data ?? []), // })
mounted-Flag im Hook verhindert
State-Updates nach dem Unmount. Teste immer im StrictMode, bevor du in Produktion gehst.
6. Performance und Scale: Throttling, Channel-Limits, Unsubscribe
Supabase Realtime skaliert vertikal mit dem gewählten Plan. Auf dem kostenlosen Plan sind 200 gleichzeitige Connections und 2 Channels pro Connection möglich. Pro- und Business-Pläne heben diese Limits auf bis zu 10.000 concurrent Connections an.
Auf Applikationsebene gibt es mehrere Stellschrauben: Clientseitiges Throttling für hochfrequente Events, gezieltes Filtern statt globaler Subscriptions, sauberes Unsubscribe beim Unmount und batching von State-Updates. Claude Code baut diese Optimierungen auf Anfrage direkt in den generierten Code ein.
Channel-Limits und Verbindungs-Budget
| Plan | Concurrent Connections | Channels / Connection | Messages / Sekunde |
|---|---|---|---|
| Free | 200 | 2 | 10 |
| Pro | 500 | 100 | 100 |
| Team | 1.000 | 100 | 500 |
| Enterprise | 10.000+ | unbegrenzt | benutzerdefiniert |
Throttle-Util fuer hochfrequente Events
// Generischer Throttle fuer Realtime-Events (z.B. Cursor, Scroll, Resize) export function throttle<T extends (...args: any[]) => any>( fn: T, limitMs: number ): T { let last = 0 return ((...args) => { const now = Date.now() if (now - last < limitMs) return last = now return fn(...args) }) as T } // Verwendung: const sendCursorThrottled = throttle(sendCursor, 50) // Max 20 Events/s document.addEventListener('mousemove', (e) => { sendCursorThrottled(e.clientX, e.clientY) })
Mehrere Channels effizient verwalten
import { useRef, useEffect } from 'react' import { supabase } from '../lib/supabase' import type { RealtimeChannel } from '@supabase/supabase-js' // Singleton-Map: verhindert doppelte Channel-Subscriptions (z.B. React StrictMode) const channelRegistry = new Map<string, RealtimeChannel>() export function getOrCreateChannel(name: string): RealtimeChannel { if (channelRegistry.has(name)) { return channelRegistry.get(name)! } const channel = supabase.channel(name) channelRegistry.set(name, channel) return channel } export async function releaseChannel(name: string): Promise<void> { const channel = channelRegistry.get(name) if (channel) { await supabase.removeChannel(channel) channelRegistry.delete(name) } } // Alle aktiven Channels beim App-Unload aufraeumen if (typeof window !== 'undefined') { window.addEventListener('beforeunload', () => { channelRegistry.forEach((ch) => supabase.removeChannel(ch)) }) }
State-Batching fuer hochfrequente Updates
import { flushSync, startTransition } from 'react' // Fuer zeitkritische Updates (z.B. eigene Nachrichten sofort anzeigen) const handleInsert = (payload: any) => { flushSync(() => setMessages((p) => [...p, payload.new])) } // Fuer nicht-dringende Updates (z.B. Background-Feed, Analytics) const handleBulkUpdate = (payload: any) => { startTransition(() => setFeedItems((p) => [...p, payload.new])) } // React 18 batcht Updates in Event-Handlern automatisch — // bei async Callbacks (Realtime-Events) muss man das selbst steuern
-
1
Filter auf Serverseite:
filter: `room_id=eq.${id}`statt clientseitiges Filtern — reduziert Traffic signifikant bei grossen Tabellen. -
2
Throttle alle Broadcast-Events auf clientseitig max. 10–20 Events/s.
Supabase limitiert serverseitig auf
eventsPerSecondaus der Client-Config. -
3
Channel-Lifecycle strikt einhalten: Immer
removeChannelim Cleanup. Verwaiste Channels zaehlen gegen das Connection-Limit. - 4 Kombiniere Channels: Postgres Changes + Broadcast + Presence in einem Channel — das spart Verbindungen und vereinfacht das Lifecycle-Management.
- 5 Monitoring mit Supabase Dashboard: Realtime-Connections und Message-Throughput sind im Dashboard unter "Reports → Realtime" sichtbar.
Monitoring und Debugging
import { createClient } from '@supabase/supabase-js' const supabase = createClient(url, anonKey, { realtime: { logger: (level, msg, data) => { if (process.env.NODE_ENV === 'development') { console.log(`[Realtime][${level}] ${msg}`, data) } }, }, }) // Aktive Channels inspizieren const activeChannels = supabase.getChannels() console.log(`Aktive Channels: ${activeChannels.length}`) activeChannels.forEach((ch) => { console.log(`- ${ch.topic}: ${ch.state}`) })
Bereit fuer Live-Daten in deinem Projekt?
Starte jetzt deinen kostenlosen Trial und nutze Claude Code als deinen Supabase-Realtime-Experten. Kein Kreditkarte erforderlich — sofort loslegen.
Kostenlos testen →