P

Echtzeit-State in Next.js: Wie Supabase Realtime und Zustand euch Live-Updates ohne Chaos liefern

Next.js
Supabase
Zustand
PostgreSQL
React
TypeScript
·23 Min. Lesezeit
Prasath Soosaithasan
von Prasath Soosaithasan
Echtzeit-State in Next.js: Wie Supabase Realtime und Zustand euch Live-Updates ohne Chaos liefern

Irgendwo verdrahtet gerade ein Frontend-Team Supabase Realtime-Subscriptions in einem useEffect, beobachtet wie ihre Komponente 47 Mal pro Sekunde neu rendert und fragt sich, warum der Browser-Tab gerade 1,2 GB RAM verschlungen hat. Die Subscription-Leaks sind subtil. Die Re-Render-Stürme nicht.

Supabase Realtime – bestehend aus Broadcast, Presence und Postgres Changes – ist eine wirklich produktionsreife Infrastruktur. Das zugrunde liegende System unterstützt Millionen gleichzeitiger Verbindungen und einen Nachrichtendurchsatz, der auch anspruchsvolle Workloads bewältigen kann. Aber die React-Integrationsmuster? Genau da verlieren Teams Zeit und liefern Bugs aus. Die offizielle Dokumentation zeigt, wie man subscribt. Sie zeigt nicht, wie man eine wartbare, performante Anwendung baut, die länger überlebt als eine Demo.

Dieser Artikel behandelt die Architektur, die tatsächlich funktioniert: Supabase Realtime Channels, die über Custom Hooks in Zustand Stores einfließen, wobei der Zustand Store als Single Source of Truth dient. Jede Komponente hört einfach auf den Store und reagiert in Echtzeit auf Updates — kein direktes Channel-Management, kein Subscription-Jonglieren. Der Ansatz ist meinungsstark. Er ist spezifisch für Next.js. Und er stammt aus der Entwicklung eines Live-Bestell-Dashboards für THAMARAI Restaurant, wo Subscription-Cleanup, Auth-Token-Refresh und Connection-State-Management keine theoretischen Überlegungen sind — sondern der Grund dafür, dass die Küche ihre Bestellungen pünktlich erhält.

Von GraphQL Subscriptions zu Supabase: Warum wir gewechselt haben

Vor Supabase Realtime war die Standardantwort für Echtzeit-Daten in einer React-Anwendung GraphQL Subscriptions. Und die funktionierten — technisch gesehen. Man konnte eine Subscription auf eine Bestell-Tabelle einrichten und bekam Live-Updates an den Client gepusht. Das Problem war nie die Fähigkeit an sich. Das Problem war der schiere Aufwand des Setups.

GraphQL-Subscriptions produktionsreif zu machen bedeutete, einen WebSocket-fähigen GraphQL-Server zu betreiben (Hasura oder ein individuelles Apollo-Server-Setup mit Subscription-Transport), den Subscription-Lebenszyklus auf dem Client zu verwalten, die Reconnection-Logik bei abgebrochenen Verbindungen zu handhaben und Authentication-Tokens über den WebSocket-Handshake zu managen. Für ein Team, das einfach nur wollte: „Wenn eine neue Bestellung eingeht, zeig sie auf dem Dashboard", war der Infrastruktur-Overhead absurd. Man pflegte eine komplette GraphQL-Subscription-Schicht — Schema-Definitionen, Resolver, Transport-Konfiguration — für etwas, das im Grunde hieß: „Sag mir, wenn sich eine Zeile ändert."

Im Oktober 2025, als wir das Echtzeit-Bestell-Dashboard für THAMARAI Restaurant entwickelt haben, nutzten wir bereits den Supabase-Stack. Authentifizierung, Datenbank, Storage — alles Supabase. Als die Anforderung für Echtzeit-Bestellverarbeitung kam (das Restaurant-Team muss eingehende Online-Bestellungen sofort sehen, und Kunden brauchen unmittelbares Feedback, wenn ihr Essen fertig ist), war die Supabase Realtime API die natürliche Erweiterung. Keine zusätzliche Infrastruktur. Kein separater WebSocket-Server. Kein GraphQL-Schema, das gepflegt werden muss. Die Realtime API dockt direkt an dieselbe Postgres-Datenbank an, die Sie bereits nutzen, respektiert dieselben Row Level Security Policies und integriert sich mit derselben Client-Bibliothek.

Die Verbesserung betrifft nicht nur den Komfort. Supabase Realtime bietet Ihnen feingranulare Zugriffsberechtigungen auf Datenbankebene — dieselben RLS-Policies, die Ihre REST-Abfragen schützen, schützen auch Ihre Realtime-Subscriptions. Bei GraphQL-Subscriptions mussten Sie die Autorisierungslogik separat in Ihren Resolvern implementieren. Mit Supabase gibt es ein einziges Sicherheitsmodell für Lese- und Schreibzugriffe sowie Echtzeit-Events.

Und ganz entscheidend: Es gibt keine Verpflichtung, Supabase in einem kostenpflichtigen Plan zu betreiben. Für THAMARAI haben wir eine selbst gehostete Supabase-Instanz aufgesetzt. Volle Kontrolle über die Infrastruktur, die Daten bleiben genau dort, wo man sie haben will, und die Realtime-Features funktionieren identisch zur gehosteten Version. Für Kunden in der Schweiz und in Deutschland — wo Datenschutzgesetze (das Schweizer Bundesgesetz über den Datenschutz und das deutsche BDSG, beide im Rahmen der DSGVO) Datenresidenz zu einem echten Thema machen — ist Self-Hosting kein Workaround. Es ist die Architektur.

Die drei Echtzeit-Grundbausteine und wann man welchen verwendet

Supabase Realtime bietet drei unterschiedliche Primitive, und sie zu verwechseln ist der erste Fehler, den Teams machen:

  • Postgres Changes — lauscht über logische Replikation auf Ihre tatsächliche Datenbank. Sie abonnieren INSERT-, UPDATE- und DELETE-Events auf bestimmten Tabellen, optional gefiltert nach Spaltenwerten. Die Payload enthält alte und neue Zeilendaten (bis zu 1 MB; Felder über 64 Bytes werden abgeschnitten, wenn das Limit erreicht wird). Das ist das, wonach die meisten zuerst greifen — und es ist oft die falsche Standardwahl für hochfrequente Updates.
  • Broadcast — latenzarmes Pub/Sub zwischen Clients, geroutet über die Realtime-Server von Supabase. Nachrichten umgehen die Datenbank vollständig (es sei denn, Sie verwenden explizit realtime.send() aus einer Datenbankfunktion). Payloads bis zu 3 MB in kostenpflichtigen Plänen. Das ist die richtige Wahl für Cursor-Tracking, Tippindikatoren, Live-Benachrichtigungen und jeden flüchtigen Zustand, der keine Persistenz benötigt.
  • Presence — verfolgt und synchronisiert den Client-Zustand über Verbindungen hinweg. Wer ist online, wer betrachtet dieses Dokument, wer bearbeitet diese Zeile. Basiert auf Broadcast mit CRDT-basierter Konfliktlösung.

Die entscheidende Erkenntnis: Postgres Changes macht einen Umweg über das WAL (Write-Ahead Log) Ihrer Datenbank. Jede Änderung löst ein Logical-Replication-Event aus, das der Realtime-Server von Supabase aufgreift und an die Subscriber verteilt. Das ist elegant für Daten, die ohnehin in Postgres geschrieben werden. Für flüchtigen UI-State wie Cursorpositionen ist es absurd — man würde Cursorkoordinaten in eine Datenbanktabelle schreiben, nur damit sie an andere Clients gesendet werden.

Die Architektur-Empfehlung: Verwenden Sie Broadcast für kurzlebigen, hochfrequenten State. Verwenden Sie Postgres Changes für autoritative Datenmutationen. Verwende Sie Presence für den Benutzerstatus. Viele Teams versuchen, alles über Postgres Changes zu machen, weil es sich „sauberer" anfühlt, ein einziges Pattern zu haben. Es ist nicht sauberer. Es ist langsamer, teurer und belastet Ihre Datenbank ohne Grund.

Für das THAMARAI-Bestellungs-Dashboard war Postgres Changes die richtige Wahl. Bestellungen sind autoritative Daten — sie werden in die Datenbank geschrieben, sie brauchen Persistenz, sie haben einen Lebenszyklus (aufgegeben → bestätigt → in Zubereitung → fertig → abgeholt). Jeder Statuswechsel ist ein Datenbank-UPDATE, und Postgres Changes liefert diese Übergänge in Echtzeit an das Dashboard. Broadcast wäre hier falsch gewesen: Man will keine flüchtigen Bestellbenachrichtigungen, die verschwinden, wenn das Küchenpersonal den Browser neu lädt.

Die naive Integration und warum sie scheitert

Das zeigt Ihnen jedes Tutorial, und genau das wird im großen Maßstab Probleme verursachen:

1// ❌ The tutorial pattern — do not ship this
2function LiveOrders() {
3 const [orders, setOrders] = useState([])
4
5 useEffect(() => {
6 // Initial fetch
7 supabase.from('orders').select('*').then(({ data }) => setOrders(data))
8
9 // Subscribe to changes
10 const channel = supabase
11 .channel('orders-changes')
12 .on('postgres_changes',
13 { event: '*', schema: 'public', table: 'orders' },
14 (payload) => {
15 // This is where it falls apart
16 setOrders(prev => /* ...merge logic here... */)
17 }
18 )
19 .subscribe()
20
21 return () => { supabase.removeChannel(channel) }
22 }, [])
23
24 return <OrderList items={orders} />
25}

Dieses Muster hat mindestens vier Probleme, die euch in der Produktion auf die Füße fallen werden:

  1. Race Condition zwischen Fetch und Subscribe. Wenn eine Bestellung nach dem SELECT, aber vor der aktiven Subscription aufgegeben wird, geht sie verloren. Es gibt kein „Subscribe ab Zeitstempel" — man erhält Events ab dem Moment, in dem der WebSocket die Subscription bestätigt, nicht ab dem Moment, in dem .subscribe() aufgerufen wurde. In einem Restaurant während des Abendansturms ist eine verpasste Bestellung kein Bug-Report — sondern ein hungriger Gast.
  2. Die Merge-Logik ist trügerisch komplex. INSERT zu verarbeiten ist einfach — anhängen. Aber UPDATE erfordert, das richtige Element zu finden und zu ersetzen. DELETE erfordert Filtern. Und wenn die Liste sortiert oder paginiert ist, muss jede Mutation diese Sortierung berücksichtigen. Das ist State-Management-Logik, die sich als simpler Callback tarnt.
  3. Re-Render-Stürme. Jedes Postgres-Change-Event ruft setOrders auf, was ein Re-Render des gesamten Komponentenbaums auslöst. Bei 50 Updates pro Sekunde an einem vollen Abend wird 50 Mal pro Sekunde neu gerendert. Reacts Reconciliation ist schnell. So schnell ist sie nicht.
  4. Subscription-Cleanup ist fragil. supabase.removeChannel(channel) im useEffect-Cleanup sieht korrekt aus, aber wenn die Komponente unmountet, bevor die Subscription vollständig verbunden ist, kann der Channel leaken. Der Supabase-Client wird versuchen, ihn erneut zu verbinden.

Die Architektur: Zustand als einzige Quelle der Wahrheit

Die Lösung besteht darin, das Echtzeit-Zustandsmanagement außerhalb von Reacts Render-Zyklus zu verlagern. Zustand ist hier das richtige Werkzeug — nicht weil es im Trend liegt, sondern weil seine Stores unabhängig vom Komponentenbaum existieren. Ein Zustand-Store kann WebSocket-Events empfangen, seinen internen State aktualisieren und nur die abonnierten Komponenten über die spezifischen Slices benachrichtigen, die sich geändert haben.

Das ist das Kernprinzip der Architektur, die wir verwenden: Der Zustand Store ist die einzige Quelle der Wahrheit. Supabase Realtime steuert die UI nicht direkt — es speist den Store. Custom Hooks erstellen und bereinigen die Listener, die den Store aktualisieren. Jede Komponente in der Anwendung abonniert einfach den Store und reagiert auf Änderungen. Die Realtime API ist ein Eingabemechanismus, kein State-Container.

Hier ist die Architektur in Schichten:

  1. Supabase Client — Singleton, verwaltet WebSocket-Verbindung, Authentifizierung und Channel-Management
  2. Custom Hooks — erstellen Realtime-Subscriptions, verknüpfen Events mit Zustand-Actions, räumen beim Unmount auf
  3. Zustand Store — besitzt den Realtime-State, verarbeitet Channel-Events, stellt Selektoren bereit
  4. React Components — abonnieren Zustand-Selektoren, greifen nie direkt auf Channels zu
1// store/realtime-orders.ts
2import { create } from 'zustand'
3import { subscribeWithSelector } from 'zustand/middleware'
4
5interface Order {
6 id: string
7 customer_name: string
8 items: OrderItem[]
9 status: 'placed' | 'confirmed' | 'preparing' | 'ready' | 'picked_up'
10 created_at: string
11 updated_at: string
12}
13
14interface OrderStore {
15 orders: Map<string, Order>
16 connectionState: 'connecting' | 'connected' | 'disconnected' | 'error'
17 lastEventAt: number | null
18
19 // Actions
20 setInitialData: (orders: Order[]) => void
21 handleInsert: (order: Order) => void
22 handleUpdate: (order: Order) => void
23 handleDelete: (id: string) => void
24 setConnectionState: (state: OrderStore['connectionState']) => void
25}
26
27export const useOrderStore = create<OrderStore>()(
28 subscribeWithSelector((set, get) => ({
29 orders: new Map(),
30 connectionState: 'disconnected',
31 lastEventAt: null,
32
33 setInitialData: (orders) =>
34 set({
35 orders: new Map(orders.map(o => [o.id, o])),
36 lastEventAt: Date.now(),
37 }),
38
39 handleInsert: (order) =>
40 set(state => {
41 const next = new Map(state.orders)
42 next.set(order.id, order)
43 return { orders: next, lastEventAt: Date.now() }
44 }),
45
46 handleUpdate: (order) =>
47 set(state => {
48 const next = new Map(state.orders)
49 const existing = next.get(order.id)
50 // Ignore stale updates
51 if (existing && existing.updated_at >= order.updated_at) return state
52 next.set(order.id, order)
53 return { orders: next, lastEventAt: Date.now() }
54 }),
55
56 handleDelete: (id) =>
57 set(state => {
58 const next = new Map(state.orders)
59 next.delete(id)
60 return { orders: next, lastEventAt: Date.now() }
61 }),
62
63 setConnectionState: (connectionState) => set({ connectionState }),
64 }))
65)

Ein paar Dinge, die auffallen:

  • Map statt Array. Die Suche nach ID ist O(1) statt O(n). Wenn Sie während eines Freitag-Abend-Ansturms schnelle Bestellstatus-Updates verarbeiten, macht das einen Unterschied.
  • Schutz vor veralteten Updates. Die handleUpdate-Methode vergleicht updated_at-Zeitstempel und ignoriert Events, die älter sind als das, was der Store bereits hat. Das löst die Race Condition, bei der Postgres Changes Events in falscher Reihenfolge liefert.
  • subscribeWithSelector-Middleware. Das ist es, was Re-Render-Stürme verhindert. Komponenten können state.orders.get('specific-id') abonnieren und werden nur neu gerendert, wenn sich genau diese Bestellung ändert — nicht wenn sich irgendeine Bestellung in der Map ändert.

Custom Hooks: Das Bindeglied zwischen Echtzeit und dem Store

Das Custom-Hook-Pattern ist der Schlüssel zu einer sauberen Architektur. Jeder Hook ist für eine Sache zuständig: ein Realtime-Abonnement erstellen, dessen Events mit den entsprechenden Zustand-Store-Actions verknüpfen und beim Unmounten der Komponente aufräumen.

1// hooks/use-realtime-orders.ts
2import { useEffect, useRef } from 'react'
3import { supabase } from '@/lib/supabase-client'
4import { useOrderStore } from '@/store/realtime-orders'
5import type { RealtimeChannel } from '@supabase/supabase-js'
6
7export function useRealtimeOrders() {
8 const channelRef = useRef<RealtimeChannel | null>(null)
9 const { setConnectionState, handleInsert, handleUpdate, handleDelete } =
10 useOrderStore.getState()
11
12 useEffect(() => {
13 // Avoid duplicate subscriptions
14 if (channelRef.current) return
15
16 setConnectionState('connecting')
17
18 const channel = supabase
19 .channel('orders-realtime')
20 .on(
21 'postgres_changes',
22 { event: 'INSERT', schema: 'public', table: 'orders' },
23 (payload) => useOrderStore.getState().handleInsert(payload.new as Order)
24 )
25 .on(
26 'postgres_changes',
27 { event: 'UPDATE', schema: 'public', table: 'orders' },
28 (payload) => useOrderStore.getState().handleUpdate(payload.new as Order)
29 )
30 .on(
31 'postgres_changes',
32 { event: 'DELETE', schema: 'public', table: 'orders' },
33 (payload) => useOrderStore.getState().handleDelete(payload.old.id)
34 )
35 .subscribe((status, err) => {
36 if (status === 'SUBSCRIBED') {
37 useOrderStore.getState().setConnectionState('connected')
38 } else if (status === 'CHANNEL_ERROR') {
39 useOrderStore.getState().setConnectionState('error')
40 console.error('Realtime channel error:', err)
41 } else if (status === 'CLOSED') {
42 useOrderStore.getState().setConnectionState('disconnected')
43 }
44 })
45
46 channelRef.current = channel
47
48 return () => {
49 if (channelRef.current) {
50 supabase.removeChannel(channelRef.current)
51 channelRef.current = null
52 useOrderStore.getState().setConnectionState('disconnected')
53 }
54 }
55 }, [])
56}

Wichtiges Detail: useOrderStore.getState() wird innerhalb jedes Callbacks aufgerufen, nicht in einer Closure erfasst. Das stellt sicher, dass immer in den aktuellen Store-State geschrieben wird und nicht in einen veralteten Snapshot von der Erstellung der Subscription.

Der Hook wird einmal auf der Layout-Ebene aufgerufen:

1// app/dashboard/layout.tsx (Client Component)
2'use client'
3
4import { useRealtimeOrders } from '@/hooks/use-realtime-orders'
5
6export default function DashboardLayout({ children }) {
7 useRealtimeOrders() // Subscribe once, feeds the store
8 return <>{children}</>
9}

Jede Kindkomponente — die Bestellliste, das Bestelldetail-Panel, der Statuszähler, die Küchenansicht — liest einfach aus dem Zustand Store. Keine von ihnen weiß, dass Supabase Realtime existiert. Keine von ihnen verwaltet Subscriptions. Sie sind reine Konsumenten von State.

1// components/order-queue.tsx
2'use client'
3
4import { useOrderStore } from '@/store/realtime-orders'
5import { useShallow } from 'zustand/react/shallow'
6
7export function OrderQueue() {
8 const activeOrders = useOrderStore(
9 useShallow(state =>
10 Array.from(state.orders.values())
11 .filter(o => o.status !== 'picked_up')
12 .sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
13 )
14 )
15
16 return (
17 <div className="order-queue">
18 {activeOrders.map(order => (
19 <OrderCard key={order.id} order={order} />
20 ))}
21 </div>
22 )
23}

Wenn die Küche eine Bestellung als „fertig" markiert, löst das Postgres-UPDATE ein Realtime-Event aus, der Custom Hook leitet es an handleUpdate im Store weiter, der Store aktualisiert die Map, und jede Komponente, die den entsprechenden Slice abonniert hat, rendert neu. Der Bestelltracker des Kunden aktualisiert sich gleichzeitig. Der gesamte Roundtrip – vom Datenbankschreibvorgang bis zum UI-Update auf beiden Bildschirmen – passiert in Millisekunden.

Die Fetch-Subscribe-Race-Condition lösen

Das Wettrennen zwischen dem initialen Datenabruf und der Aktivierung des Abonnements ist die häufigste Ursache für verpasste Ereignisse. Die Lösung ist, zuerst zu abonnieren, Ereignisse zu puffern, dann abzurufen und anschließend den Puffer abzuspielen:

1// hooks/use-realtime-sync.ts
2export function useRealtimeOrdersSync() {
3 const initialized = useRef(false)
4
5 useEffect(() => {
6 if (initialized.current) return
7 initialized.current = true
8
9 const store = useOrderStore.getState()
10 const eventBuffer: Array<{
11 eventType: string
12 new?: Order
13 old?: { id: string }
14 }> = []
15 let isBuffering = true
16
17 // 1. Subscribe first — buffer events until initial fetch completes
18 const channel = supabase
19 .channel('orders-sync')
20 .on(
21 'postgres_changes',
22 { event: '*', schema: 'public', table: 'orders' },
23 (payload) => {
24 if (isBuffering) {
25 eventBuffer.push(payload)
26 return
27 }
28 // Normal processing after buffer is flushed
29 const s = useOrderStore.getState()
30 if (payload.eventType === 'INSERT') s.handleInsert(payload.new as Order)
31 if (payload.eventType === 'UPDATE') s.handleUpdate(payload.new as Order)
32 if (payload.eventType === 'DELETE') s.handleDelete(payload.old.id)
33 }
34 )
35 .subscribe(async (status) => {
36 if (status !== 'SUBSCRIBED') return
37
38 // 2. Fetch current state AFTER subscription is confirmed
39 const { data } = await supabase
40 .from('orders')
41 .select('*')
42 .order('created_at', { ascending: true })
43
44 if (data) store.setInitialData(data)
45
46 // 3. Replay buffered events (deduplicating against fetched data)
47 isBuffering = false
48 for (const event of eventBuffer) {
49 const s = useOrderStore.getState()
50 if (event.eventType === 'INSERT') s.handleInsert(event.new as Order)
51 if (event.eventType === 'UPDATE') s.handleUpdate(event.new as Order)
52 if (event.eventType === 'DELETE') s.handleDelete(event.old.id)
53 }
54 eventBuffer.length = 0
55
56 store.setConnectionState('connected')
57 })
58
59 return () => {
60 supabase.removeChannel(channel)
61 useOrderStore.getState().setConnectionState('disconnected')
62 }
63 }, [])
64}

Der Schutz gegen veraltete Updates in handleUpdate ist das, was das Replay sicher macht. Wenn die abgerufenen Daten bereits ein Update enthalten, das auch gepuffert wurde, ignoriert der Zeitstempelvergleich das Duplikat stillschweigend. Keine spezielle Deduplizierungslogik nötig – der Store übernimmt das.

Authentifizierungstoken-Aktualisierung und Verbindungswiederherstellung

Supabase Realtime verwendet denselben JWT-Token wie der REST-Client. Wenn der Token erneuert wird (was automatisch über onAuthStateChange geschieht), muss die WebSocket-Verbindung aktualisiert werden. Der Supabase-Client erledigt dies intern — aber nur, wenn Sie dieselbe Client-Instanz sowohl für Auth als auch für Realtime verwenden.

Das ist ein weiterer Grund, warum das Singleton-Pattern wichtig ist:

1// lib/supabase-client.ts
2import { createBrowserClient } from '@supabase/ssr'
3
4export const supabase = createBrowserClient(
5 process.env.NEXT_PUBLIC_SUPABASE_URL,
6 process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
7)

Ein Client. Wird für Authentifizierung, für Datenbankabfragen und für Realtime-Subscriptions verwendet. Token-Aktualisierungen werden automatisch an den WebSocket weitergegeben.

Aber die Wiederherstellung der Verbindung betrifft nicht nur Tokens. WebSocket-Verbindungen brechen ab — Netzwerkwechsel, Laptop-Ruhezustand/Aufwachen, mobile Apps im Hintergrund. Der Supabase-Client hat eine eingebaute Wiederverbindung mit exponentiellem Backoff, aber Ihr Zustand-Store muss über den Verbindungsstatus Bescheid wissen, damit die UI angemessen reagieren kann:

1// components/connection-status.tsx
2export function ConnectionStatus() {
3 const connectionState = useOrderStore(state => state.connectionState)
4
5 if (connectionState === 'connected') return null
6
7 return (
8 <div className={`connection-banner ${connectionState}`}>
9 {connectionState === 'connecting' && 'Reconnecting to live updates...'}
10 {connectionState === 'error' && 'Live updates unavailable — showing last known state'}
11 {connectionState === 'disconnected' && 'Offline — updates paused'}
12 </div>
13 )
14}

Für das THAMARAI-Küchen-Dashboard war dieses Banner zur Verbindungsanzeige unverzichtbar. Das WLAN des Restaurants fiel gelegentlich aus, und das Küchenpersonal musste sofort wissen, ob die angezeigte Bestellliste aktuell oder veraltet war. Eine stille Verbindungsunterbrechung — bei der die Oberfläche normal aussieht, aber keine Updates mehr empfängt — ist schlimmer als ein offensichtlicher Fehler. Die Bestellung des Gastes könnte bereits fertig sein, aber das Dashboard zeigt immer noch „in Zubereitung" an.

Skalierung über eine Tabelle hinaus: Das Multi-Store-Muster

Eine echte Anwendung hat mehr als eine Echtzeit-Entität. Das THAMARAI-Dashboard abonnierte Bestellungen, aber auch Tischzuweisungen und die Verfügbarkeit von Menüpunkten (die Küche kann ein Gericht als ausverkauft markieren, und die Bestell-Website zeigt das sofort an). Jede Entität bekommt ihren eigenen Zustand-Store und ihren eigenen Custom Hook:

1// hooks/use-realtime-menu.ts
2export function useRealtimeMenu() { /* same pattern, targets 'menu_items' table */ }
3
4// hooks/use-realtime-tables.ts
5export function useRealtimeTables() { /* same pattern, targets 'table_assignments' table */ }
6
7// app/dashboard/layout.tsx
8export default function DashboardLayout({ children }) {
9 useRealtimeOrders()
10 useRealtimeMenu()
11 useRealtimeTables()
12 return <>{children}</>
13}

Jeder Hook verwaltet seinen eigenen Channel. Jeder Store verwaltet seinen eigenen State. Komponenten können Daten aus mehreren Stores zusammensetzen, ohne dass ein Store von den anderen weiß. Das skaliert sauber — eine neue Realtime-Entität hinzuzufügen bedeutet eine neue Store-Datei und einen neuen Hook, keine Änderung an bestehendem Code.

Postgres-Änderungslimits und der hybride Ansatz

Postgres Changes hat drei Limits, die in der Praxis relevant sind:

  • Nutzlastgröße: Maximal 1 MB pro Ereignis. Zeilendaten, die diesen Wert überschreiten, werden abgeschnitten. Wenn Sie große JSON-Spalten speichern, beachten Sie, dass die Realtime-Nutzlast möglicherweise nicht die vollständigen Daten enthält. Große JSON-Spalten werden stillschweigend abgeschnitten.
  • Durchsatz: An das Nachrichten-pro-Sekunde-Limit Ihres Tarifs gebunden. Bei Pro sind das 500/s über alle Kanäle hinweg. Eine stark frequentierte Tabelle mit häufigen Schreibvorgängen kann dieses Budget aufbrauchen und andere Subscriptions aushungern. Bei einer selbst gehosteten Instanz kontrollieren Sie diese Limits selbst — allerdings ist dann Ihre Server-Hardware der begrenzende Faktor.
  • WAL-Abhängigkeit: Postgres Changes nutzt logische Replikation. Hohe Schreibvolumen vergrößern das WAL, was die Datenbankleistung beeinträchtigen kann. Es handelt sich um denselben Replikations-Slot, der auch andere Funktionen von Supabase antreibt.

Für das THAMARAI-Dashboard war der Durchsatz von Postgres Changes nie ein Problem — ein gut besuchtes Restaurant verarbeitet vielleicht ein paar hundert Bestellungen pro Abend, was locker in jedem vernünftigen Rahmen liegt. Aber für Szenarien mit hohem Durchsatz — etwa IoT-Dashboards, Trading-Bildschirme oder Monitoring-Systeme — funktioniert der hybride Ansatz besser:

  1. Schreiben Sie Daten ganz normal in Postgres (für Persistenz und Abfragen)
  2. Verwenden Sie realtime.broadcast_changes() oder Broadcast über die REST-API aus einem Datenbank-Trigger oder Backend-Service, um Updates an Clients zu pushen
  3. Verwenden Sie Postgres Changes nur für seltene, aber wichtige Mutationen (Benutzereinstellungen, Konfigurationsänderungen, Erstellung neuer Entitäten)

Das entkoppelt Ihren Echtzeit-Fanout von Ihrem WAL, gibt Ihnen die höheren Broadcast-Payload-Limits (3 MB vs. 1 MB) und lässt Sie das Payload gestalten — Sie senden nur die Felder, die Clients tatsächlich benötigen, statt der gesamten Zeile.

1-- Database function that broadcasts a shaped payload
2CREATE OR REPLACE FUNCTION broadcast_metric_update()
3RETURNS trigger AS $$
4BEGIN
5 PERFORM realtime.send(
6 jsonb_build_object(
7 'metric_id', NEW.id,
8 'value', NEW.value,
9 'timestamp', NEW.recorded_at
10 ),
11 'metric_update', -- event name
12 'metrics-live', -- topic/channel
13 false -- private (requires auth)
14 );
15 RETURN NEW;
16END;
17$$ LANGUAGE plpgsql;
18
19CREATE TRIGGER on_metric_insert
20 AFTER INSERT ON metrics
21 FOR EACH ROW EXECUTE FUNCTION broadcast_metric_update();

Präsenz: Online-Status richtig gemacht

Presence ist das einfachste der drei Primitives bei der Integration und das am leichtesten falsch einsetzbare. Der häufige Fehler: zu viel State im Presence-Objekt zu tracken.

Supabase Presence beschränkt Sie auf 10 Schlüssel pro Presence-Objekt. Das ist keine willkürliche Grenze — der Presence-Status wird bei jeder Änderung mit jedem verbundenen Client synchronisiert. Wenn Sie das vollständige Profil, die Berechtigungen und die Einstellungen eines Nutzers in sein Presence-Objekt packen, wird dieser gesamte Payload bei jeder Statusänderung an jeden Teilnehmer gesendet.

1// ✅ Minimal presence — just what other clients need to render
2const channel = supabase.channel('dashboard-room')
3channel.subscribe(async (status) => {
4 if (status !== 'SUBSCRIBED') return
5
6 await channel.track({
7 user_id: session.user.id,
8 display_name: session.user.user_metadata.name,
9 avatar_url: session.user.user_metadata.avatar_url,
10 current_view: 'orders', // what tab/page they're on
11 online_at: new Date().toISOString(),
12 })
13})
14
15// Zustand store for presence — same pattern
16interface PresenceStore {
17 users: Map<string, PresenceUser>
18 syncPresence: (state: any) => void
19}
20
21export const usePresenceStore = create<PresenceStore>((set) => ({
22 users: new Map(),
23 syncPresence: (presenceState) => {
24 const users = new Map<string, PresenceUser>()
25 for (const [key, presences] of Object.entries(presenceState)) {
26 // Each user can have multiple presences (multiple tabs)
27 // Take the most recent one
28 const latest = (presences as any[]).sort(
29 (a, b) => new Date(b.online_at).getTime() - new Date(a.online_at).getTime()
30 )[0]
31 if (latest) users.set(latest.user_id, latest)
32 }
33 set({ users })
34 },
35}))

Verbinden Sie die Presence-Sync-Events über einen eigenen Hook mit dem Store, und die Komponenten erhalten eine saubere Map<string, PresenceUser> zum Rendern. Keine doppelten Nutzer durch mehrere Tabs, keine veralteten Einträge.

Next.js-spezifische Besonderheiten

Server Components und Echtzeit

Realtime-Subscriptions sind grundsätzlich clientseitig — sie erfordern eine WebSocket-Verbindung vom Browser. Server Components können keine Realtime-Channels abonnieren. Die Architektur sieht folgendermaßen aus:

  • Server Components rufen die initialen Daten ab (über den Supabase-Server-Client mit cookies())
  • Initiale Daten werden als Props an eine Client-Component-Grenze übergeben
  • Die Client Component hydriert den Zustand Store und aktiviert Realtime-Subscriptions über den benutzerdefinierten Hook

Das ist ein Feature, keine Einschränkung. Servergerenderte Anfangsdaten bedeuten, dass die Seite sofort nutzbar ist. Realtime-Subscriptions erweitern sie progressiv mit Live-Updates. Beim THAMARAI-Dashboard sieht das Küchenpersonal die aktuelle Bestellwarteschlange im selben Moment, in dem die Seite lädt — kein Lade-Spinner, kein Warten auf die WebSocket-Verbindung. Live-Updates legen sich nahtlos darüber.

App Router und Parallel Routes

Wenn Ihr Dashboard parallele Routen (die @slot-Konvention) verwendet, beachten Sie, dass jeder Slot ein eigener React-Baum ist. Ein Zustand-Store wird über alle Slots hinweg geteilt (er ist ein Modul-Singleton), aber das Layout jedes Slots hat seinen eigenen Effect-Lebenszyklus. Zentralisieren Sie Ihre Channel-Subscriptions im übergeordneten Layout, nicht in einzelnen Slots — genau deshalb funktioniert der Custom-Hook-Ansatz so gut. Ein einziger Hook-Aufruf im übergeordneten Layout, und alle Slots lesen aus demselben Store.

Edge-Runtime-Kompatibilität

Der JavaScript-Client von Supabase funktioniert in der Edge Runtime (Middleware, Edge-API-Routen), aber Realtime-Subscriptions nicht. Die WebSocket-API in der Edge Runtime ist eingeschränkt. Belassen Sie sämtliche Realtime-Logik in standardmäßigem clientseitigem Code. Wenn Sie serverseitig ausgelöste Realtime-Events benötigen, verwenden Sie die Broadcast-REST-API von Supabase in Ihren API-Routen:

1// app/api/notify/route.ts
2import { createClient } from '@supabase/supabase-js'
3
4const supabaseAdmin = createClient(
5 process.env.SUPABASE_URL,
6 process.env.SUPABASE_SERVICE_ROLE_KEY
7)
8
9export async function POST(req: Request) {
10 const { message, channel } = await req.json()
11
12 // Broadcast from server — no WebSocket needed
13 await supabaseAdmin.channel(channel).send({
14 type: 'broadcast',
15 event: 'server-notification',
16 payload: { message },
17 })
18
19 return Response.json({ sent: true })
20}

Echtzeit-Integrationen testen

Das Testen von Echtzeit-Features ist bekanntermaßen mühsam. Zwei Ansätze, die tatsächlich funktionieren:

Integrationstests mit Supabase Local Dev

Die Supabase CLI (supabase start) führt einen vollständigen lokalen Stack inklusive Realtime aus — egal ob Sie die gehostete Plattform oder Self-Hosting in Produktion nutzt. Ihre Integrationstests können:

  1. Supabase lokal starten
  2. Ein Abonnement über den Supabase-Client erstellen
  3. Eine Zeile über den Admin-Client einfügen
  4. Überprüfen, dass der Abonnement-Callback mit dem korrekten Payload ausgelöst wird

Das testet die gesamte Pipeline — Postgres-Trigger → WAL → Realtime-Server → WebSocket → Ihr Callback → Zustand-Store-Update.

Unit-Tests mit einem Mock-Channel

Für Unit-Tests der Zustand-Store-Logik mocke den Channel vollständig. Der Store ist reines State-Management — keine WebSocket-Abhängigkeit:

1// __tests__/realtime-orders.test.ts
2import { useOrderStore } from '@/store/realtime-orders'
3
4beforeEach(() => {
5 useOrderStore.setState({
6 orders: new Map(),
7 connectionState: 'disconnected',
8 lastEventAt: null,
9 })
10})
11
12test('handleUpdate ignores stale events', () => {
13 const store = useOrderStore.getState()
14
15 store.handleInsert({
16 id: '1', customer_name: 'Test', items: [],
17 status: 'placed', created_at: '2025-10-15T18:00:00Z',
18 updated_at: '2025-10-15T18:00:00Z'
19 })
20
21 // Stale update (earlier timestamp)
22 store.handleUpdate({
23 id: '1', customer_name: 'Test', items: [],
24 status: 'confirmed', created_at: '2025-10-15T18:00:00Z',
25 updated_at: '2025-10-15T17:55:00Z'
26 })
27
28 expect(useOrderStore.getState().orders.get('1')?.status).toBe('placed')
29})
30
31test('order status transitions update correctly', () => {
32 const store = useOrderStore.getState()
33
34 store.handleInsert({
35 id: '1', customer_name: 'Test', items: [],
36 status: 'placed', created_at: '2025-10-15T18:00:00Z',
37 updated_at: '2025-10-15T18:00:00Z'
38 })
39
40 store.handleUpdate({
41 id: '1', customer_name: 'Test', items: [],
42 status: 'ready', created_at: '2025-10-15T18:00:00Z',
43 updated_at: '2025-10-15T18:15:00Z'
44 })
45
46 expect(useOrderStore.getState().orders.get('1')?.status).toBe('ready')
47})

Diese Trennung — Store-Logik getestet ohne WebSocket-Infrastruktur — ist einer der größten Vorteile der Zustand-Architektur. Sie können dutzende Edge-Case-Tests für Order-Zustandsübergänge schreiben, ohne jemals eine echte Supabase-Instanz anfassen zu müssen.

Supabase vs Firebase: Die Entscheidung für Unternehmen

Schweizer und deutsche Enterprise-Teams, die Supabase Realtime als Firebase-Alternative evaluieren, sollten die Kompromisse klar kennen:

  • Datenstandort und Compliance: Bei Supabase können Sie Ihre Postgres-Region wählen (einschließlich EU — konkret Frankfurt) und es kann vollständig selbst gehostet werden. Die Realtime Database von Firebase bietet nur eingeschränkte Regionskontrolle und kann nicht selbst gehostet werden. Für Workloads, die dem Schweizer Bundesgesetz über den Datenschutz (DSG), dem deutschen Bundesdatenschutzgesetz (BDSG) und der übergeordneten DSGVO unterliegen, ist dies oft der entscheidende Faktor. Supabase selbst zu hosten bedeutet, dass Ihre Daten niemals Infrastruktur verlassen, die Sie kontrollieren.
  • SQL vs. NoSQL: Supabase ist Postgres. Ihr Datenmodell ist relational, Ihre Abfragen sind SQL, Ihre Authentifizierung integriert sich mit Row Level Security auf Datenbankebene. Die Realtime Database von Firebase ist ein JSON-Baum. Für komplexe Dashboards mit Joins, Aggregationen und Reporting-Anforderungen gewinnt Postgres eindeutig.
  • Self-Hosting: Supabase kann selbst gehostet werden. Firebase nicht. Für Unternehmenskunden mit On-Premises-Anforderungen — verbreitet im Schweizer Bankwesen, der deutschen Automobilindustrie und regulierten Branchen in beiden Ländern — ist Self-Hosting keine Option, sondern eine Voraussetzung.
  • Realtime-Reife: Die Realtime Database von Firebase verfügt über ein Jahrzehnt an Produktionshärtung. Supabase Realtime ist jünger, entwickelt sich aber rasant weiter. Die Broadcast- und Presence-Funktionen sind solide; Postgres Changes können bei extremen Schreibvolumen aufgrund der WAL-Abhängigkeit verzögert reagieren.
  • Preismodell: Supabase berechnet nach Verbindungen und Nachrichten pro Sekunde (mit großzügigen Pro-Plan-Limits). Firebase berechnet pro Verbindung und pro übertragenes Datenvolumen. Für Anwendungen mit vielen Verbindungen, aber geringem Datenvolumen (viele inaktive Dashboards), ist Supabase tendenziell günstiger. Für Anwendungen mit wenigen Verbindungen, aber hohem Durchsatz sollten Sie genau nachrechnen. Oder Sie hosten selbst und zahlen nur für Ihre eigene Serverinfrastruktur.

Die vollständige Architektur, zusammengefasst

1┌─────────────────────────────────────────────────┐
2│ React Components │
3│ (subscribe to Zustand selectors) │
4└───────────────────┬─────────────────────────────┘
5 │ useShallow selectors
6┌───────────────────▼─────────────────────────────┐
7│ Zustand Stores │
8│ ┌──────────┐ ┌──────────┐ ┌────────────┐ │
9│ │ Orders │ │ Menu │ │ Presence │ │
10│ │ Store │ │ Store │ │ Store │ │
11│ └────▲─────┘ └────▲─────┘ └─────▲──────┘ │
12└────────┼────────────┼──────────────┼────────────┘
13 │ │ │
14┌────────┼────────────┼──────────────┼────────────┐
15│ Custom Hooks (create + clean up listeners) │
16│ │ │ │ │
17│ postgres_changes postgres_changes presence │
18└────────┼────────────┼──────────────┼────────────┘
19 │ │ │
20┌────────▼────────────▼──────────────▼────────────┐
21│ Supabase Client (singleton) │
22│ WebSocket Connection │
23│ Auth Token Management │
24└─────────────────────────────────────────────────┘

Jede Schicht hat eine einzige Verantwortung. Custom Hooks verwalten den Subscription-Lebenszyklus. Channels gelangen nie in den Komponentencode. Komponenten verwalten nie Subscriptions. Der Zustand Store – die einzige Quelle der Wahrheit – übernimmt Event-Deduplizierung und -Sortierung. Auth-Refresh verbindet Channels transparent neu.

Das ist nicht die einfachste Architektur. Das Tutorial-Muster mit useEffect und useState ist einfacher. Es ist aber auch die Architektur, die genau in dem Moment versagt, in dem der Restaurantbesitzer dem Personal an einem vollen Freitagabend das neue System vorführt. Echtzeit-Features haben eine eigentümliche Eigenschaft: Sie funktionieren perfekt in der Entwicklung, meistens im Staging, und scheitern in der Produktion auf genau die Weise, die man nicht getestet hat.

Bauen Sie die langweilige, geschichtete Architektur. Ihr zukünftiges Ich – das, das freitagabends um 22:00 Uhr ein WebSocket-Problem debuggt – wird es Ihnen danken.

Kontaktaufnahme

z.B. Ludwig van Beethoven
z.B. ludwig@beethoven.co
Falls das eine Herausforderung anspricht, vor der Sie stehen, freue ich mich auf einen Austausch darüber, wie ich helfen kann.