Web Development
Next.jsSupabaseRealtimePostgreSQLRLSWebSocketsApp RouterChatPresence

Next.js Supabase Realtime in 2026: End-to-End Blueprint for Chat, Presence, and Collaboration

AO
Adrijan Omićević
·14 min read

# What You’ll Build and Why It Matters#

This guide is an end-to-end blueprint for Next.js Supabase realtime features in the App Router: chat messages, presence, typing indicators, activity feeds, and lightweight collaboration signals.

Realtime is rarely “just subscribe and render”. In production you must handle RLS, optimistic UI, deduplication, reconnect gaps, and scaling fan-out. This post gives a practical architecture that survives real user behavior.

If you want additional background on realtime transports and alternatives, read Next.js real-time features with WebSockets, SSE, and Supabase Realtime. For a full SaaS baseline, see Next.js + Supabase SaaS starter architecture.

# Architecture Overview: Durable vs Ephemeral Realtime#

You’ll typically have two realtime “streams”:

  1. 1

    Durable events stored in Postgres and delivered via Supabase Realtime Postgres changes.

    • Chat messages
    • Activity feed entries
    • Document updates that you want to persist
  2. 2

    Ephemeral signals that should not hit your database frequently.

    • Presence and online users
    • Typing indicators
    • Cursor position, “viewing section”, live selection

Supabase supports both:

  • Postgres change feeds for durable data.
  • Presence on channels for ephemeral state.
FeatureBacking storeSupabase featureChannel namingNotes
Chat messagesPostgresPostgres changesroom:{roomId}Subscribe to inserts on messages scoped to room
Presence (online)In-memory channelPresenceroom:{roomId}One channel can carry both Presence and DB events
TypingIn-memory channelBroadcastroom:{roomId}Broadcast is low-latency and avoids DB writes
Activity feedPostgresPostgres changesworkspace:{workspaceId}Fan-out can get large, consider partitioning
Collaboration “someone is viewing”In-memory channelPresence or Broadcastdoc:{docId}Keep transient to avoid write amplification

🎯 Key Takeaway: Persist only what you must audit or replay. Everything else should be Presence or Broadcast to keep your database fast and your bill predictable.

# Prerequisites#

RequirementVersionNotes
Next.js14 or 15App Router examples assume modern React Server Components setup
SupabaseLatestUse @supabase/supabase-js v2
PostgresSupabase-managedEnable RLS and Realtime replication on required tables
AuthSupabase AuthExamples assume auth.uid() is available in policies

# Data Model: Schema Design for Realtime Chat, Presence, and Activity#

Design your schema so it supports:

  • Room scoping
  • Membership checks
  • Efficient ordering and pagination
  • Deduplication and idempotency

Core tables#

TablePurposeKey columnsRealtime events
workspacesTenant boundaryid, nameRare
workspace_membersAuthorization scopeworkspace_id, user_id, roleSometimes
roomsChat channelsid, workspace_id, nameModerate
room_membersRoom-level accessroom_id, user_idModerate
messagesDurable chatid, room_id, user_id, content, created_at, client_idHigh
activity_eventsDurable feedid, workspace_id, type, actor_id, payload, created_atHigh

A key production detail is client_id on messages for optimistic UI reconciliation and deduplication.

SQL schema (minimal, production-ready)#

SQL
create table public.workspaces (
  id uuid primary key default gen_random_uuid(),
  name text not null,
  created_at timestamptz not null default now()
);
 
create table public.workspace_members (
  workspace_id uuid not null references public.workspaces(id) on delete cascade,
  user_id uuid not null,
  role text not null default 'member',
  created_at timestamptz not null default now(),
  primary key (workspace_id, user_id)
);
 
create table public.rooms (
  id uuid primary key default gen_random_uuid(),
  workspace_id uuid not null references public.workspaces(id) on delete cascade,
  name text not null,
  created_at timestamptz not null default now()
);
 
create table public.room_members (
  room_id uuid not null references public.rooms(id) on delete cascade,
  user_id uuid not null,
  created_at timestamptz not null default now(),
  primary key (room_id, user_id)
);
 
create table public.messages (
  id uuid primary key default gen_random_uuid(),
  room_id uuid not null references public.rooms(id) on delete cascade,
  user_id uuid not null,
  content text not null,
  client_id uuid not null,
  created_at timestamptz not null default now()
);
 
create index on public.messages (room_id, created_at desc);
create unique index messages_room_client_id_unique
  on public.messages (room_id, client_id);
 
create table public.activity_events (
  id uuid primary key default gen_random_uuid(),
  workspace_id uuid not null references public.workspaces(id) on delete cascade,
  actor_id uuid not null,
  type text not null,
  payload jsonb not null default '{}'::jsonb,
  created_at timestamptz not null default now()
);
 
create index on public.activity_events (workspace_id, created_at desc);

Enable Realtime replication for tables#

Supabase Realtime needs the tables in the publication.

SQL
alter publication supabase_realtime add table public.messages;
alter publication supabase_realtime add table public.activity_events;

ℹ️ Note: Presence and Broadcast do not require database replication. Only Postgres changes do.

# RLS: Secure Reads and Writes Without Breaking Realtime#

Realtime is only useful if it is secure. The most common production incident is “users can see events they shouldn’t” or “events never arrive due to policy mismatch”.

Principles that keep RLS maintainable#

  • Always scope by workspace_id or room_id.
  • Avoid policies that require joins with large tables without indexes.
  • Ensure membership tables have primary keys and are indexed.
  • Use exists checks that match your access model.

RLS policies for chat#

SQL
alter table public.messages enable row level security;
 
create policy "read messages in rooms I belong to"
on public.messages for select
to authenticated
using (
  exists (
    select 1
    from public.room_members rm
    where rm.room_id = messages.room_id
      and rm.user_id = auth.uid()
  )
);
 
create policy "insert messages in rooms I belong to"
on public.messages for insert
to authenticated
with check (
  user_id = auth.uid()
  and exists (
    select 1
    from public.room_members rm
    where rm.room_id = messages.room_id
      and rm.user_id = auth.uid()
  )
);

RLS policies for activity feed#

SQL
alter table public.activity_events enable row level security;
 
create policy "read activity in my workspaces"
on public.activity_events for select
to authenticated
using (
  exists (
    select 1
    from public.workspace_members wm
    where wm.workspace_id = activity_events.workspace_id
      and wm.user_id = auth.uid()
  )
);
 
create policy "insert activity in my workspaces"
on public.activity_events for insert
to authenticated
with check (
  actor_id = auth.uid()
  and exists (
    select 1
    from public.workspace_members wm
    where wm.workspace_id = activity_events.workspace_id
      and wm.user_id = auth.uid()
  )
);

⚠️ Warning: If you test Realtime with a service role key, you can accidentally bypass RLS and think everything works. In the browser, your realtime channel uses the user JWT, so missing policies will silently block events.

# Next.js App Router Setup: Server Fetch + Client Subscribe#

A stable realtime UI uses a two-phase data flow:

  1. 1
    Server component loads the initial snapshot for fast first render and SEO-friendly layout.
  2. 2
    Client component subscribes to realtime updates and applies them incrementally.

Server component: initial messages#

TypeScript
// app/rooms/[roomId]/page.tsx
import { createClient } from "@/lib/supabase/server";
 
export default async function RoomPage({ params }: { params: { roomId: string } }) {
  const supabase = await createClient();
 
  const { data: messages } = await supabase
    .from("messages")
    .select("id, room_id, user_id, content, client_id, created_at")
    .eq("room_id", params.roomId)
    .order("created_at", { ascending: false })
    .limit(50);
 
  return (
    <div>
      <h1>Room</h1>
      {/* Render client component with initial snapshot */}
      {/* Pass only serializable data */}
      {/* eslint-disable-next-line */}
      {/* @ts-ignore */}
      <RoomRealtime roomId={params.roomId} initialMessages={messages ?? []} />
    </div>
  );
}

Client component: subscribe + presence + optimistic inserts#

TypeScript
"use client";
 
import { useEffect, useMemo, useRef, useState } from "react";
import { createClient } from "@/lib/supabase/client";
 
type Message = {
  id: string;
  room_id: string;
  user_id: string;
  content: string;
  client_id: string;
  created_at: string;
};
 
export function RoomRealtime({
  roomId,
  initialMessages,
}: {
  roomId: string;
  initialMessages: Message[];
}) {
  const supabase = useMemo(() => createClient(), []);
  const [messages, setMessages] = useState<Message[]>(initialMessages);
  const [typingUsers, setTypingUsers] = useState<string[]>([]);
  const seenClientIds = useRef(new Set<string>());
 
  useEffect(() => {
    for (const m of initialMessages) seenClientIds.current.add(m.client_id);
  }, [initialMessages]);
 
  useEffect(() => {
    const channel = supabase.channel(`room:${roomId}`, {
      config: { presence: { key: "user" } },
    });
 
    channel
      .on(
        "postgres_changes",
        { event: "INSERT", schema: "public", table: "messages", filter: `room_id=eq.${roomId}` },
        (payload) => {
          const m = payload.new as Message;
          if (seenClientIds.current.has(m.client_id)) return;
          seenClientIds.current.add(m.client_id);
          setMessages((prev) => [m, ...prev]);
        }
      )
      .on("broadcast", { event: "typing" }, (payload) => {
        const users = (payload.payload?.users as string[]) ?? [];
        setTypingUsers(users);
      })
      .on("presence", { event: "sync" }, () => {
        const state = channel.presenceState();
        const online = Object.keys(state);
        // You can render online count from this
      })
      .subscribe();
 
    return () => {
      supabase.removeChannel(channel);
    };
  }, [supabase, roomId]);
 
  return (
    <div>
      <div>{typingUsers.length ? "Someone is typing..." : null}</div>
      <ul>
        {messages.map((m) => (
          <li key={m.id}>{m.content}</li>
        ))}
      </ul>
    </div>
  );
}

# Optimistic UI: Idempotency, Reconciliation, and Ordering#

Optimistic UI is where most realtime chat UIs break: duplicates, out-of-order messages, and “ghost” pending messages.

The pattern that works#

  • Generate client_id per message on the client.
  • Insert the message immediately into UI with a status of pending.
  • Insert into Postgres with client_id.
  • When realtime INSERT arrives, reconcile by client_id.
  • Enforce uniqueness on (room_id, client_id) to guarantee idempotency.

Client-side send function (with optimistic insert)#

TypeScript
import { randomUUID } from "crypto";
 
async function sendMessage(opts: {
  supabase: any;
  roomId: string;
  userId: string;
  content: string;
  setMessages: any;
  seenClientIds: any;
}) {
  const clientId = randomUUID();
  const now = new Date().toISOString();
 
  opts.seenClientIds.current.add(clientId);
 
  opts.setMessages((prev: any[]) => [
    {
      id: `pending:${clientId}`,
      room_id: opts.roomId,
      user_id: opts.userId,
      content: opts.content,
      client_id: clientId,
      created_at: now,
      status: "pending",
    },
    ...prev,
  ]);
 
  const { error } = await opts.supabase.from("messages").insert({
    room_id: opts.roomId,
    user_id: opts.userId,
    content: opts.content,
    client_id: clientId,
  });
 
  if (error) {
    opts.setMessages((prev: any[]) =>
      prev.map((m) => (m.client_id === clientId ? { ...m, status: "failed" } : m))
    );
  }
}

Ordering strategy#

In chat, users expect stable ordering. Use created_at for display, but be aware two messages can share the same timestamp under load.

Practical approach:

  • Order by (created_at, id) on fetch.
  • In UI, insert realtime items and then sort when rendering if needed.
  • Keep list length bounded to avoid memory growth.

# Presence and Typing: Use Ephemeral Channels, Not Tables#

Presence is not a database problem. If you write presence to Postgres, you create:

  • High write frequency
  • Lock contention
  • Vacuum pressure
  • Unnecessary realtime feed noise

Presence: track online users per room#

When the channel is subscribed, track the user.

TypeScript
await channel.track({
  user_id: userId,
  name: displayName,
  last_seen: new Date().toISOString(),
});

To render online users, read presenceState() and map the values. Keep the payload small, because presence state is broadcasted.

Typing indicators: broadcast with throttling#

Typing should be broadcast and throttled to avoid spamming.

TypeScript
let typingTimeout: any;
 
function setTyping(channel: any, userId: string, isTyping: boolean) {
  clearTimeout(typingTimeout);
 
  channel.send({
    type: "broadcast",
    event: "typing",
    payload: { users: isTyping ? [userId] : [] },
  });
 
  if (isTyping) {
    typingTimeout = setTimeout(() => {
      channel.send({ type: "broadcast", event: "typing", payload: { users: [] } });
    }, 1200);
  }
}

💡 Tip: Treat typing and cursors as best-effort signals. If a message arrives, clear typing immediately and don’t wait for timeouts.

# Activity Feeds and Collaboration Signals#

Activity feeds are durable by design. The common pitfall is to log too much and create an expensive hot table.

What to log#

Good activity events:

  • “Task moved to Done”
  • “User invited”
  • “Comment added”

Bad activity events:

  • Every keystroke
  • Every cursor move
  • “User is reading section 3” every 5 seconds

Activity payload design#

Use a small, versioned payload:

  • type is a stable identifier.
  • payload is structured and minimal.
  • If you need full context, fetch the referenced entity.

Example payload:

  • type = "message.created"
  • payload = { "room_id": "...", "message_id": "..." }

This keeps events small and reduces outbound bandwidth.

# Scaling Considerations: Fan-Out, Rate, and Cost#

Realtime scaling is mostly about fan-out per channel and event rate.

Practical scaling guidelines#

ConcernSymptomFix
Too many subscribers per channelLatency spikes, dropped updatesSplit channels by room, document, or workspace; avoid “global” channels
Too many DB writes for ephemeral stateHigh CPU, high IOMove to Presence/Broadcast; debounce updates
Heavy payloadsSlow clients, high egressSend ids and fetch details; keep payloads lean
Hot partitionsOne room dominates trafficConsider sharding rooms or limiting participant count per room
Unbounded UI stateTab memory growsKeep a message window, virtualize list

Throughput reality check#

Even moderate usage can be spiky:

  • 200 concurrent users in a room
  • 1 message per second
  • That is 200 message deliveries per second, plus presence overhead

If you push typing updates at 5 per second per user, you can easily multiply event volume by 10x to 50x. Use throttle and keep ephemeral events best-effort.

# Troubleshooting: Duplicates, Reconnect Gaps, and Permission Mismatches#

This section is the difference between a demo and a production system.

Duplicate events#

Common causes

  • React Strict Mode runs effects twice in development.
  • Component remounts without channel cleanup.
  • Multiple tabs subscribe and you treat each event as unique.
  • You optimistically insert and also add the realtime insert without reconciliation.

Fix checklist

  1. 1
    Ensure you call removeChannel on unmount.
  2. 2
    Use a stable supabase client instance.
  3. 3
    Deduplicate by client_id and or id.
  4. 4
    Add a unique constraint on (room_id, client_id).

Reconnect logic and missed events#

A websocket reconnect can miss some inserts. If you rely only on realtime, your UI can drift.

Blueprint

  • On subscribe, fetch the latest N messages.
  • Track the latest seen created_at and id.
  • On reconnect, refetch messages newer than your last known marker.

Supabase channels expose status changes, so you can trigger reconciliation.

TypeScript
channel.subscribe((status: string) => {
  if (status === "SUBSCRIBED") {
    // Optionally refetch newest items to close gaps
  }
});

Permission mismatches and “silent” failures#

Symptoms:

  • You can fetch rows, but realtime never fires.
  • Some users get events, others do not.
  • Inserts succeed but subscribers don’t receive updates.

Root causes:

  • Table not added to supabase_realtime publication.
  • RLS SELECT policy missing or too strict.
  • Client uses anon key without auth session.
  • Filter mismatch, wrong schema or table name.

Debug checklist:

  • Confirm the user is authenticated in the browser.
  • Test SELECT under the same user session.
  • Verify publication includes the table.
  • Temporarily widen RLS policy to isolate the issue, then tighten again.

⚠️ Warning: A policy that allows INSERT but denies SELECT will let the sender write, but subscribers might not see the row via realtime because SELECT is blocked. Realtime delivery must respect what the subscriber can select.

Event storms and UI thrashing#

If you update state on every event, React rendering can become a bottleneck.

Fixes:

  • Batch updates when possible.
  • Keep lists virtualized for large rooms.
  • Only subscribe to what the user is currently viewing.

# Observability: Logging and Monitoring Realtime in Production#

Realtime issues are often intermittent: specific networks, mobile backgrounding, or token refresh failures. Add instrumentation early.

What to log:

  • Channel name and subscription status changes
  • Reconnect counts
  • Insert errors and RLS failures
  • Client-side dedup hits

For a production monitoring stack on Vercel, see Next.js logging and monitoring with Sentry, OpenTelemetry, and Vercel.

# Key Takeaways#

  • Design durable tables for chat and activity, and keep ephemeral signals like typing and cursors on Presence or Broadcast to avoid write amplification.
  • Use client_id plus a unique constraint per room for idempotency, optimistic UI reconciliation, and duplicate event prevention.
  • Treat realtime as an incremental layer: always do an initial fetch and run a reconciliation fetch after reconnect to prevent missed events.
  • Enable RLS everywhere and write explicit membership-based SELECT and INSERT policies, because realtime delivery depends on subscriber SELECT permissions.
  • Scale by reducing fan-out and payload size: split channels by room or document, throttle broadcasts, and keep UI state bounded.

# Conclusion#

Next.js App Router plus Supabase Realtime is a strong combination for chat, presence, and collaboration, but production reliability comes from the boring parts: schema choices, RLS correctness, deduplication, and reconnect reconciliation.

If you want Samioda to implement a realtime subsystem end-to-end, including RLS audits, optimistic UI, and load testing, contact us via samioda.com and share your product requirements and expected concurrency.

FAQ

Share
A
Adrijan OmićevićFounder & Senior Developer

Founder & Senior Developer at Samioda. 8+ years building React, Next.js, Flutter and n8n automation solutions for clients across Europe.

Need help with your project?

We build custom solutions using the technologies discussed in this article. Senior team, fixed prices.