# 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
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
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.
Recommended channel model#
| Feature | Backing store | Supabase feature | Channel naming | Notes |
|---|---|---|---|---|
| Chat messages | Postgres | Postgres changes | room:{roomId} | Subscribe to inserts on messages scoped to room |
| Presence (online) | In-memory channel | Presence | room:{roomId} | One channel can carry both Presence and DB events |
| Typing | In-memory channel | Broadcast | room:{roomId} | Broadcast is low-latency and avoids DB writes |
| Activity feed | Postgres | Postgres changes | workspace:{workspaceId} | Fan-out can get large, consider partitioning |
| Collaboration “someone is viewing” | In-memory channel | Presence or Broadcast | doc:{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#
| Requirement | Version | Notes |
|---|---|---|
| Next.js | 14 or 15 | App Router examples assume modern React Server Components setup |
| Supabase | Latest | Use @supabase/supabase-js v2 |
| Postgres | Supabase-managed | Enable RLS and Realtime replication on required tables |
| Auth | Supabase Auth | Examples 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#
| Table | Purpose | Key columns | Realtime events |
|---|---|---|---|
workspaces | Tenant boundary | id, name | Rare |
workspace_members | Authorization scope | workspace_id, user_id, role | Sometimes |
rooms | Chat channels | id, workspace_id, name | Moderate |
room_members | Room-level access | room_id, user_id | Moderate |
messages | Durable chat | id, room_id, user_id, content, created_at, client_id | High |
activity_events | Durable feed | id, workspace_id, type, actor_id, payload, created_at | High |
A key production detail is client_id on messages for optimistic UI reconciliation and deduplication.
SQL schema (minimal, production-ready)#
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.
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_idorroom_id. - Avoid policies that require joins with large tables without indexes.
- Ensure membership tables have primary keys and are indexed.
- Use
existschecks that match your access model.
RLS policies for chat#
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#
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:
- 1Server component loads the initial snapshot for fast first render and SEO-friendly layout.
- 2Client component subscribes to realtime updates and applies them incrementally.
Server component: initial messages#
// 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#
"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_idper message on the client. - Insert the message immediately into UI with a
statusofpending. - 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)#
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.
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.
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:
typeis a stable identifier.payloadis 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#
| Concern | Symptom | Fix |
|---|---|---|
| Too many subscribers per channel | Latency spikes, dropped updates | Split channels by room, document, or workspace; avoid “global” channels |
| Too many DB writes for ephemeral state | High CPU, high IO | Move to Presence/Broadcast; debounce updates |
| Heavy payloads | Slow clients, high egress | Send ids and fetch details; keep payloads lean |
| Hot partitions | One room dominates traffic | Consider sharding rooms or limiting participant count per room |
| Unbounded UI state | Tab memory grows | Keep 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
- 1Ensure you call
removeChannelon unmount. - 2Use a stable
supabaseclient instance. - 3Deduplicate by
client_idand orid. - 4Add 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
Nmessages. - Track the latest seen
created_atandid. - On reconnect, refetch messages newer than your last known marker.
Supabase channels expose status changes, so you can trigger reconciliation.
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_realtimepublication. - 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_idplus 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
Founder & Senior Developer at Samioda. 8+ years building React, Next.js, Flutter and n8n automation solutions for clients across Europe.
More in Web Development
All →Next.js Authorization in the App Router: RBAC vs ABAC with Middleware, RLS, and Policy Patterns
A practical guide to Next.js authorization in the App Router using RBAC and ABAC — with middleware checks, server component guards, database-enforced RLS, decision matrix, and copy-paste policy patterns.
React Testing Strategy in 2026: Vitest + React Testing Library + MSW for Confident Releases
A pragmatic React testing strategy for 2026 using Vitest, React Testing Library, and MSW. Learn a realistic test pyramid, reduce flakiness, and ship confidently with CI-ready patterns.
Modern React Frontend Architecture: Feature-Based Modules, Boundaries, and Scalability
A practical guide to React frontend architecture feature-based modules: clear boundaries, shared layers, dependency rules, and a maintainable folder strategy for React and Next.js.
Need help with your project?
We build custom solutions using the technologies discussed in this article. Senior team, fixed prices.
Related Articles
Next.js + Supabase SaaS Starter Architecture (App Router): Auth, RLS, Billing, and Multi-Tenancy
A production-ready blueprint for a Next.js App Router + Supabase SaaS starter architecture: auth, Postgres data model, RLS policies, Stripe billing, and multi-tenant organization design with concrete examples.
Next.js Authorization in the App Router: RBAC vs ABAC with Middleware, RLS, and Policy Patterns
A practical guide to Next.js authorization in the App Router using RBAC and ABAC — with middleware checks, server component guards, database-enforced RLS, decision matrix, and copy-paste policy patterns.
Next.js + Supabase RLS for Multi‑Tenant SaaS: Policies, Roles, and Safe Data Access
A practical guide to Next.js App Router and Supabase Row Level Security for multi-tenant SaaS: table design, policies, roles, server-side access patterns, common pitfalls, and a deployment checklist.