Build This Now
Build This Now
O que é o Código Claude?Instalar o Claude CodeInstalador Nativo do Claude CodeO Teu Primeiro Projeto com Claude Code
Subscription BillingMulti-Tenant SaaSAI Chat FeatureRate LimitingStripe WebhooksSemantic SearchRealtime UpdatesDrip Email SequencesClaude Code v2.1.122 Release NotesClaude Code Dynamic Workflows: Como Orquestrar 1.000 Subagentes Num Codebase RealMelhores Práticas do Claude CodeBoas Práticas para o Claude Opus 4.7Claude Code num VPSIntegração GitRevisão de Código com ClaudeWorktrees no Claude CodeControle Remoto do Claude CodeChannels do Claude CodeChannels, Routines, Teleport, DispatchTarefas Agendadas no Claude CodePermissões do Claude CodeModo Auto do Claude CodeAdicionar Pagamentos Stripe Com o Claude CodeFeedback LoopsFluxos de Trabalho com TodosTarefas no Claude CodeTemplates de ProjetoPreços e Consumo de Tokens no Claude CodePreços do Claude Code: O Que Vais Mesmo PagarClaude Code Ultra ReviewConstruir Uma App Next.js Com o Claude CodeClaude Code With Supabase: Database, Auth, RLSVercel deepsec with Claude CodeTest-Driven Development with Claude CodeComo Construir um MVP de SaaS Com o Claude CodeAdicionar Autenticação Com o Claude Code (Supabase Auth)Adicionar Email Transacional Com o Claude Code (Resend + React Email)Construir Uma API Type-Safe Com o 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)Comércio Agêntico: Como Construir uma App Que Agentes de IA Podem PagarClaude Code 1M Context in Practice: When Bigger Isn't BetterClaude Code GitHub Actions Setup Guide (@claude + Cron)Headless ModeMax Plan vs APIPrompt CachingQuanto Custa Construir um SaaS com Claude Code em 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.

Pare de configurar. Comece a construir.

Templates SaaS com orquestração de IA.

Veja o que construímos para empresas →
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.


Pare de configurar. Comece a construir.

Templates SaaS com orquestração de IA.

Veja o que construímos para empresas →

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.

Pare de configurar. Comece a construir.

Templates SaaS com orquestração de IA.

Veja o que construímos para empresas →

Posted by @speedy_devv

Continue in Workflow

  • Comércio Agêntico: Como Construir uma App Que Agentes de IA Podem Pagar
    Um guia em português simples sobre comércio agêntico em 2026: o que fazem o x402, o ACP e o Machine Payments Protocol, mais um passo a passo de fim de semana para lançar uma API paga que agentes de IA podem comprar.
  • Melhores Práticas do Claude Code
    Cinco hábitos separam os engenheiros que entregam com Claude Code: PRDs, regras modulares em CLAUDE.md, slash commands personalizados, resets com /clear e uma mentalidade de evolução do sistema.
  • Modo Auto do Claude Code
    Um segundo modelo Sonnet revê cada chamada de ferramenta do Claude Code antes de ser executada. O que o modo auto bloqueia, o que permite e as regras de permissão que cria nas tuas definições.
  • Channels, Routines, Teleport, Dispatch
    As quatro funcionalidades de Claude Code que a Anthropic lançou em março e abril de 2026 e que transformam a CLI numa camada de coordenação orientada a eventos entre telemóvel, web e 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

  • Fundamentos do agente
    Cinco maneiras de criar agentes especializados no Código Claude: Sub-agentes de tarefas, .claude/agents YAML, comandos de barra personalizados, personas CLAUDE.md e prompts de perspetiva.
  • Engenharia de Harness para Agentes
    O harness é cada camada ao redor do seu agente de IA, exceto o modelo em si. Aprenda os cinco pontos de controle, o paradoxo das restrições, e por que o design do harness determina o desempenho do agente mais do que o modelo.
  • Padrões de Agentes
    Orchestrator, fan-out, cadeia de validação, routing especializado, refinamento progressivo e watchdog. Seis formas de orquestração para ligar sub-agentes no Claude Code.
  • Boas Práticas para Equipas de Agentes
    Padrões testados em produção para Equipas de Agentes Claude Code. Prompts de criação ricos em contexto, tarefas bem dimensionadas, posse de ficheiros, modo delegado, e correções das versões v2.1.33-v2.1.45.

Pare de configurar. Comece a construir.

Templates SaaS com orquestração de IA.

Veja o que construímos para empresas →

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

Pare de configurar. Comece a construir.

Templates SaaS com orquestração de IA.

Veja o que construímos para empresas →