Build This Now
Build This Now
Qu'est-ce que le code Claude ?Installer Claude CodeL'installateur natif de Claude CodeTon premier projet Claude Code
Subscription BillingMulti-Tenant SaaSAI Chat FeatureRate LimitingStripe WebhooksSemantic SearchRealtime UpdatesDrip Email SequencesClaude Code v2.1.122 Release NotesClaude Code Dynamic Workflows : comment orchestrer 1 000 sous-agents sur une vraie codebaseBonnes pratiques Claude CodeMeilleures pratiques pour Claude Opus 4.7Claude Code sur un VPSIntégration GitRevue de code avec Claude CodeLes Worktrees avec Claude CodeClaude Code à distanceClaude Code ChannelsChannels, Routines, Teleport, DispatchTâches planifiées avec Claude CodePermissions Claude CodeLe mode auto de Claude CodeAjouter les paiements Stripe avec Claude CodeFeedback LoopsWorkflows TodoGestion des tâches dans Claude CodeTemplates de projetTarification et utilisation des tokens Claude CodeTarifs de Claude Code : ce que tu vas vraiment payerClaude Code Ultra ReviewConstruire une app Next.js avec Claude CodeClaude Code With Supabase: Database, Auth, RLSVercel deepsec with Claude CodeTest-Driven Development with Claude CodeConstruire un MVP SaaS avec Claude CodeAjouter l'authentification avec Claude Code (Supabase Auth)Ajouter les emails transactionnels avec Claude Code (Resend + React Email)Construire une API type-safe avec Claude Code (oRPC + Zod)How to Add File Uploads With Claude Code (Supabase Storage)How to Run Background Jobs with Claude Code and InngestHow to Build an Admin Dashboard With Claude CodeHow to Add Full-Text Search With Claude Code (Postgres)Commerce agentique : comment construire une app que les agents IA peuvent payerClaude Code 1M Context in Practice: When Bigger Isn't BetterClaude Code GitHub Actions Setup Guide (@claude + Cron)Headless ModeMax Plan vs APIPrompt CachingCombien coûte la création d'un SaaS avec Claude Code en 2026Run a Team of AI Agents in Parallel with Git WorktreesPrompt Injection in Coding Agents: How to Not Get Pwned
speedy_devvkoen_salo
Blog/Handbook/Workflow/Realtime Updates

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.

Arrête de tout configurer. Place à la construction.

Des templates SaaS avec orchestration IA.

Découvrez ce que nous construisons pour les entreprises →
speedy_devvWritten by speedy_devvPublished Jul 14, 202610 min readHandbook hubWorkflow index

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.


Arrête de tout configurer. Place à la construction.

Des templates SaaS avec orchestration IA.

Découvrez ce que nous construisons pour les entreprises →

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/ssr

The 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 --noEmit

Clean production build:

npm run build

Ask 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.

Arrête de tout configurer. Place à la construction.

Des templates SaaS avec orchestration IA.

Découvrez ce que nous construisons pour les entreprises →

Posted by @speedy_devv

Continue in Workflow

  • Commerce agentique : comment construire une app que les agents IA peuvent payer
    Un guide en français simple du commerce agentique en 2026 : ce que font x402, ACP et le Machine Payments Protocol, plus un pas-à-pas d'un week-end pour livrer une API payante que les agents IA peuvent acheter.
  • Bonnes pratiques Claude Code
    Cinq habitudes séparent les ingénieurs qui livrent avec Claude Code : les PRDs, les règles CLAUDE.md modulaires, les slash commands personnalisés, les resets /clear, et un état d'esprit d'évolution du système.
  • Le mode auto de Claude Code
    Un second modèle Sonnet examine chaque appel d'outil Claude Code avant qu'il s'exécute. Ce que le mode auto bloque, ce qu'il autorise, et les règles d'autorisation qu'il place dans tes paramètres.
  • Channels, Routines, Teleport, Dispatch
    Les quatre fonctionnalités Claude Code livrées par Anthropic en mars et avril 2026 qui transforment le CLI en une couche de coordination orientée événements, entre téléphone, web et desktop.
  • Claude Code 1M Context in Practice: When Bigger Isn't Better
    The 1M-token context window is GA at flat pricing, but bigger isn't always better. A decision framework, token-cost math, and when to use /compact, subagents, and dynamic workflows instead.
  • How to Build an Admin Dashboard With Claude Code
    Ship an internal admin panel with Claude Code: role-gated routes, a searchable users and orders table with pagination, impersonation-safe RLS, and metrics tiles pulled through a type-safe API.

More from Handbook

  • Principes de base de l'agent
    Cinq façons de construire des agents spécialisés dans le code Claude : Sous-agents de tâches, .claude/agents YAML, commandes slash personnalisées, personas CLAUDE.md, et invites de perspective.
  • L'ingénierie du harness agent
    Le harness, c'est toutes les couches autour de ton agent IA sauf le modèle lui-même. Découvre les cinq leviers de contrôle, le paradoxe des contraintes, et pourquoi le design du harness détermine les performances de l'agent bien plus que le modèle.
  • Patterns d'agents
    Orchestrateur, fan-out, chaîne de validation, routage par spécialiste, raffinement progressif, et watchdog. Six formes d'orchestration pour câbler des sub-agents Claude Code.
  • Meilleures pratiques des équipes d'agents
    Patterns éprouvés pour les équipes d'agents Claude Code. Prompts de création riches en contexte, tâches bien calibrées, propriété des fichiers, mode délégué, et correctifs v2.1.33-v2.1.45.

Arrête de tout configurer. Place à la construction.

Des templates SaaS avec orchestration IA.

Découvrez ce que nous construisons pour les entreprises →

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.

On this page

The Three Primitives, Plainly
Set Up the Database
Wire the Supabase Clients
Instant List Updates With Postgres Changes
Presence: Who Is In The Room
Typing Indicators With Broadcast
The Server Component
The Client Component
The Gotchas That Bite
Ship It: The Quality Gates

Arrête de tout configurer. Place à la construction.

Des templates SaaS avec orchestration IA.

Découvrez ce que nous construisons pour les entreprises →