Realtime Updates with Claude Code and Supabase
Ship live-updating UI (presence, typing indicators, and instant list updates) using Supabase Realtime channels and Postgres change subscriptions, with no polling and no websocket server of your own.
Hören Sie auf zu konfigurieren. Fangen Sie an zu bauen.
SaaS-Builder-Vorlagen mit KI-Orchestrierung.
Problem: You want a chat room, a live list, or an activity feed that updates the instant something changes, for every viewer, without a refresh. The usual answers are grim: poll the database every few seconds, or stand up and babysit your own websocket server. Both are work you should not be doing.
Quick Win: Supabase Realtime gives you three primitives (Postgres Changes, Presence, and Broadcast) over a single hosted WebSocket. Claude Code can wire all three into a Next.js 16 app in one session. This guide builds a live chat room: instant new-message updates, a live online count, and typing indicators, with no polling and no server of your own.
Hören Sie auf zu konfigurieren. Fangen Sie an zu bauen.
SaaS-Builder-Vorlagen mit KI-Orchestrierung.
The Three Primitives, Plainly
Supabase Realtime is one WebSocket connection carrying three different kinds of traffic. Knowing which primitive to reach for is most of the job.
Postgres Changes streams database row changes. When a row is inserted, updated, or deleted, Supabase captures it from the write-ahead log and pushes it to subscribed clients. Row-level security applies, so a client only sees changes to rows it could read anyway. This is your source of truth for lists and feeds.
Presence tracks who is currently connected and what lightweight state they are sharing. It is not a stream of events, it is a live snapshot of "who is here right now." Supabase syncs it across clients and cleans up automatically when someone disconnects. This is your online count and your avatar stack.
Broadcast sends low-latency ephemeral messages between clients that never touch the database. Typing indicators, cursor positions, and "user is looking at this" pings all fit here. Nothing gets persisted, and that is the point.
A good rule for Claude Code, worth putting in CLAUDE.md: if the data must survive a refresh, it belongs in Postgres and you subscribe with Postgres Changes. If it is throwaway signal, use Broadcast. If it is "who is online," use Presence.
Set Up the Database
We need one table for messages and we need to tell Supabase to publish its changes. Ask Claude to generate the migration, then apply it. The table stores the room, the author, and the body. Row-level security is on from the first line, because every table in a real app should have it.
-- supabase/migrations/0001_messages.sql
create table public.messages (
id uuid primary key default gen_random_uuid(),
room_id text not null,
user_id uuid not null references auth.users (id) on delete cascade,
author_name text not null,
body text not null,
created_at timestamptz not null default now()
);
alter table public.messages enable row level security;
create policy "Authenticated users can read messages"
on public.messages for select
using (auth.role() = 'authenticated');
create policy "Users insert their own messages"
on public.messages for insert
with check (auth.uid() = user_id);
create index messages_room_created_idx
on public.messages (room_id, created_at);Creating the table is not enough. Postgres Changes only fires for tables added to the supabase_realtime publication. This one line is the piece everyone forgets, and its absence is the most common reason a subscription silently receives nothing.
-- supabase/migrations/0002_realtime.sql
alter publication supabase_realtime add table public.messages;Wire the Supabase Clients
You need two clients: a browser client for the realtime subscriptions and a server client for the initial page load. Both come from @supabase/ssr. Install it first.
npm install @supabase/supabase-js @supabase/ssrThe browser client is what the realtime hooks use. It reads the public URL and anon key from environment variables. Keep it in a factory function so each component gets a fresh instance.
// 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!
);
}The server client reads and refreshes the auth session from cookies. In Next.js 16, cookies() is async, so the factory is async and you await it. The empty catch handles the case where a Server Component tries to set cookies, which is not allowed and is safely refreshed elsewhere.
// lib/supabase/server.ts
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
export async function createClient() {
const cookieStore = await cookies();
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll();
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
);
} catch {
// Called from a Server Component. The proxy refreshes the session, so this is safe to ignore.
}
},
},
}
);
}Instant List Updates With Postgres Changes
Here is the core of the feature. A hook takes the room id and the initial messages fetched on the server, then subscribes to inserts on the messages table filtered to this room. Every new row arrives as a payload and gets appended to state. No polling, no refetch.
The filter string keeps you from receiving traffic for other rooms. The duplicate check matters because the sender may already have the row in state before the realtime event lands, and you do not want it twice.
// hooks/use-messages.ts
"use client";
import { useEffect, useState } from "react";
import { createClient } from "@/lib/supabase/client";
export interface Message {
id: string;
room_id: string;
author_name: string;
body: string;
created_at: string;
}
export function useMessages(roomId: string, initial: Message[]) {
const [messages, setMessages] = useState<Message[]>(initial);
useEffect(() => {
const supabase = createClient();
const channel = supabase
.channel(`room:${roomId}:messages`)
.on(
"postgres_changes",
{
event: "INSERT",
schema: "public",
table: "messages",
filter: `room_id=eq.${roomId}`,
},
(payload) => {
const next = payload.new as Message;
setMessages((current) => {
if (current.some((m) => m.id === next.id)) return current;
return [...current, next];
});
}
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, [roomId]);
return messages;
}The cleanup function calling removeChannel is not optional. Without it, every navigation or hot reload leaks a subscription, and you will eventually hit the connection ceiling with a room that seems to work fine in development and falls over in production.
Presence: Who Is In The Room
Presence answers "who is online" without you writing any tracking logic. You configure the channel with a presence key (the user id keeps one entry per user across tabs), listen for the sync event, and read the current state. When you subscribe, you call track to announce yourself.
The presenceState return is keyed by presence key, and each key holds an array of entries (one per connection). Taking the first entry per key gives you a clean list of unique users.
// hooks/use-presence.ts
"use client";
import { useEffect, useState } from "react";
import { createClient } from "@/lib/supabase/client";
export interface PresenceUser {
userId: string;
name: string;
onlineAt: string;
}
export function usePresence(
roomId: string,
me: { userId: string; name: string }
) {
const [online, setOnline] = useState<PresenceUser[]>([]);
useEffect(() => {
const supabase = createClient();
const channel = supabase.channel(`room:${roomId}:presence`, {
config: { presence: { key: me.userId } },
});
channel
.on("presence", { event: "sync" }, () => {
const state = channel.presenceState<PresenceUser>();
const users = Object.values(state).map((entries) => entries[0]);
setOnline(users);
})
.subscribe(async (status) => {
if (status !== "SUBSCRIBED") return;
await channel.track({
userId: me.userId,
name: me.name,
onlineAt: new Date().toISOString(),
});
});
return () => {
supabase.removeChannel(channel);
};
}, [roomId, me.userId, me.name]);
return online;
}You do not handle disconnects yourself. Close a tab or drop the network, and Supabase removes that entry and fires a fresh sync to everyone else. The online count corrects itself within a couple of seconds.
Typing Indicators With Broadcast
Typing is the textbook case for Broadcast: high frequency, zero value once it is a second old, and something you never want in your database. The hook listens for a typing broadcast, ignores its own echoes, and shows the sender for a short window before clearing them.
The timeout per user is what makes it feel right. Someone types, you show "Alex typing...", and if no new event arrives in 2.5 seconds you drop them. Each incoming event resets that user's timer, so a steady typist stays visible.
// hooks/use-typing.ts
"use client";
import { useEffect, useRef, useState } from "react";
import { createClient } from "@/lib/supabase/client";
import type { RealtimeChannel } from "@supabase/supabase-js";
export function useTyping(
roomId: string,
me: { userId: string; name: string }
) {
const [typing, setTyping] = useState<Record<string, string>>({});
const channelRef = useRef<RealtimeChannel | null>(null);
const timers = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
useEffect(() => {
const supabase = createClient();
const channel = supabase.channel(`room:${roomId}:typing`);
channel
.on("broadcast", { event: "typing" }, ({ payload }) => {
const { userId, name } = payload as { userId: string; name: string };
if (userId === me.userId) return;
setTyping((current) => ({ ...current, [userId]: name }));
clearTimeout(timers.current[userId]);
timers.current[userId] = setTimeout(() => {
setTyping((current) => {
const next = { ...current };
delete next[userId];
return next;
});
}, 2500);
})
.subscribe();
channelRef.current = channel;
const activeTimers = timers.current;
return () => {
Object.values(activeTimers).forEach(clearTimeout);
supabase.removeChannel(channel);
};
}, [roomId, me.userId]);
function notifyTyping() {
channelRef.current?.send({
type: "broadcast",
event: "typing",
payload: { userId: me.userId, name: me.name },
});
}
return { typing: Object.values(typing), notifyTyping };
}One caution worth handing to Claude: do not call notifyTyping on every keystroke unthrottled. It fires a network message each time. Throttling it to roughly one send per second is plenty, and it keeps you far from any rate limit. The version above is intentionally simple so the mechanics are clear.
The Server Component
The page fetches the last hundred messages on the server so the first paint has real content, then hands them to the client component as initialMessages. Params are a Promise in Next.js 16, so you await them.
// app/rooms/[roomId]/page.tsx
import { createClient } from "@/lib/supabase/server";
import { Room } from "@/components/room";
import type { Message } from "@/hooks/use-messages";
interface PageProps {
params: Promise<{ roomId: string }>;
}
export default async function RoomPage({ params }: PageProps) {
const { roomId } = await params;
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
const { data: messages } = await supabase
.from("messages")
.select("id, room_id, author_name, body, created_at")
.eq("room_id", roomId)
.order("created_at", { ascending: true })
.limit(100);
return (
<Room
roomId={roomId}
initialMessages={(messages ?? []) as Message[]}
me={{
userId: user?.id ?? "anon",
name: (user?.user_metadata?.name as string) ?? "Guest",
}}
/>
);
}Do not put "use cache" on this page. Realtime output is per-user and per-moment, the opposite of cacheable. This route should render on request. Cache the static shell around it if you want, never the live room itself.
The Client Component
This is where the three hooks meet the UI. It renders the message list, the online count from presence, the typing line from broadcast, and an input that both sends messages and fires typing pings. Sending inserts directly through the browser client, and the insert policy from the migration enforces that you can only write your own rows.
// components/room.tsx
"use client";
import { useState } from "react";
import { createClient } from "@/lib/supabase/client";
import { useMessages, type Message } from "@/hooks/use-messages";
import { usePresence } from "@/hooks/use-presence";
import { useTyping } from "@/hooks/use-typing";
interface RoomProps {
roomId: string;
initialMessages: Message[];
me: { userId: string; name: string };
}
export function Room({ roomId, initialMessages, me }: RoomProps) {
const messages = useMessages(roomId, initialMessages);
const online = usePresence(roomId, me);
const { typing, notifyTyping } = useTyping(roomId, me);
const [draft, setDraft] = useState("");
async function send() {
const body = draft.trim();
if (!body) return;
setDraft("");
const supabase = createClient();
await supabase.from("messages").insert({
room_id: roomId,
user_id: me.userId,
author_name: me.name,
body,
});
}
return (
<main className="mx-auto flex max-w-lg flex-col gap-4 py-10">
<header className="flex items-center justify-between">
<h1 className="text-xl font-bold">Room {roomId}</h1>
<span className="text-sm text-neutral-500">
{online.length} online
</span>
</header>
<ul className="flex flex-col gap-2">
{messages.map((m) => (
<li key={m.id} className="rounded-md border p-2 text-sm">
<span className="font-medium">{m.author_name}: </span>
{m.body}
</li>
))}
</ul>
<p className="h-5 text-sm text-neutral-500">
{typing.length > 0 ? `${typing.join(", ")} typing...` : ""}
</p>
<div className="flex gap-2">
<input
value={draft}
onChange={(e) => {
setDraft(e.target.value);
notifyTyping();
}}
onKeyDown={(e) => {
if (e.key === "Enter") send();
}}
className="flex-1 rounded-md border px-3 py-2"
placeholder="Type a message"
/>
<button
onClick={send}
className="rounded-md bg-black px-4 py-2 text-white"
>
Send
</button>
</div>
</main>
);
}Open this route in two browser windows. Type in one and the message appears in the other with no refresh. The online count reads two. Start typing in one and the other shows the typing line. That is presence, broadcast, and Postgres changes all working over a single connection.
The Gotchas That Bite
A short list to hand Claude Code so it avoids the traps that make realtime look broken when it is not.
The publication line. If Postgres Changes receives nothing, the table is almost certainly missing from the supabase_realtime publication. It fails silently, with no error, which is the worst kind of bug. Check it first, every time.
RLS blocks the stream too. Postgres Changes respects row-level security. If your select policy is wrong, the client subscribes successfully and still receives nothing, because it is not allowed to read those rows. A subscription that connects but stays empty usually means a policy problem, not a realtime problem.
Always remove the channel. Every hook here returns a cleanup that calls removeChannel. Leaked channels are the number one cause of a realtime feature that degrades over a session and dies under load.
Know the scale ceiling of Postgres Changes. It is perfect up to a point. Supabase recommends moving high-volume database streams to Broadcast (fired from a database trigger) once you expect more than roughly 3,000 concurrent subscribers watching the same changes. For a chat room, a dashboard, or a feed, you are nowhere near that. Build with Postgres Changes now and switch the busiest streams later if you ever need to.
Ship It: The Quality Gates
Realtime code has more moving parts than a normal feature (a subscription lifecycle, cleanup, and typed payloads), so the checks before you commit matter more, not less.
Type check with zero errors:
npx tsc --noEmitClean production build:
npm run buildAsk Claude to run both and fix what surfaces:
claude "run tsc --noEmit and npm run build, fix any type errors in the realtime hooks, and confirm both pass clean"A single Claude Code session handles this feature well. Turning that into a repeatable pipeline is what the $29 Code Kit adds on top of Claude Code (a one-time $29, no subscription): a harness that plans the feature, builds it, evaluates the output, and tests it against a real browser, so every feature exits with zero type errors, zero lint errors, and a clean build before it merges. Claude Code itself still runs on your own paid Anthropic plan. The Code Kit is the process around it, not a replacement for it.
Hören Sie auf zu konfigurieren. Fangen Sie an zu bauen.
SaaS-Builder-Vorlagen mit KI-Orchestrierung.
Posted by @speedy_devv
Hören Sie auf zu konfigurieren. Fangen Sie an zu bauen.
SaaS-Builder-Vorlagen mit KI-Orchestrierung.
Semantic Search
Add embeddings-backed semantic search to a Supabase app. Generate embeddings, store them in pgvector, rank results by meaning, and serve it all through a type-safe oRPC endpoint.
Drip Email Sequences
Wire a multi-step lifecycle drip as a durable Inngest step function that sleeps between sends, cancels when the user converts, and delivers every email through Resend.