Build a Real-Time Chat App with Next.js 15 + Supabase: The Complete Production Build
A complete real-time chat app built with Next.js 15 + Supabase. Schema design, RLS policies, channels, presence, typing indicators, message pagination, optimistic UI, and production gotchas.
Build a Real-Time Chat App with Next.js 15 + Supabase: The Complete Production Build#
This guide walks through building a working real-time chat app from zero — schema, RLS, channels, presence, typing indicators, pagination, optimistic updates, and the production gotchas that bite everyone.
By the end you will have an app where:
- Multiple users can join named rooms
- Messages appear instantly without refresh
- Online/offline state shows in real time
- "User is typing..." shows without database writes
- Old messages paginate via infinite scrollback
- Sent messages appear instantly via optimistic UI
- Other users cannot read rooms they are not in (RLS enforced)
Stack and prerequisites#
- Next.js 15 App Router
- Supabase (Free or Pro)
@supabase/ssrand@supabase/supabase-js- TypeScript
Prior knowledge: you can read the Supabase Auth + Middleware: The Complete Session Management Guide for Next.js 15 for the auth setup if it is unfamiliar. This guide assumes a working Supabase auth integration.
Step 1: Schema#
Three tables: rooms, room_members, messages.
CREATE TABLE rooms (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
created_by uuid REFERENCES auth.users(id) NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE room_members (
room_id uuid REFERENCES rooms(id) ON DELETE CASCADE,
user_id uuid REFERENCES auth.users(id) ON DELETE CASCADE,
joined_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (room_id, user_id)
);
CREATE TABLE messages (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
room_id uuid REFERENCES rooms(id) ON DELETE CASCADE NOT NULL,
user_id uuid REFERENCES auth.users(id) ON DELETE CASCADE NOT NULL,
content text NOT NULL CHECK (length(content) > 0 AND length(content) <= 4000),
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_messages_room_created ON messages(room_id, created_at DESC);
CREATE INDEX idx_room_members_user ON room_members(user_id);
The idx_messages_room_created is critical — every chat query is "messages in this room ordered by time", and the descending order index makes scrollback pagination O(log n).
Step 2: RLS policies#
This is where security lives. Get this wrong and any user can read any room.
ALTER TABLE rooms ENABLE ROW LEVEL SECURITY;
ALTER TABLE room_members ENABLE ROW LEVEL SECURITY;
ALTER TABLE messages ENABLE ROW LEVEL SECURITY;
-- Rooms: any authenticated user can read rooms they're a member of
CREATE POLICY "members can read rooms"
ON rooms FOR SELECT
USING (
EXISTS (
SELECT 1 FROM room_members
WHERE room_members.room_id = rooms.id
AND room_members.user_id = auth.uid()
)
);
-- Rooms: any authenticated user can create a room
CREATE POLICY "users can create rooms"
ON rooms FOR INSERT
WITH CHECK (auth.uid() = created_by);
-- Room members: users can see members of their own rooms
CREATE POLICY "members can read other members"
ON room_members FOR SELECT
USING (
EXISTS (
SELECT 1 FROM room_members rm
WHERE rm.room_id = room_members.room_id
AND rm.user_id = auth.uid()
)
);
-- Room members: users can join rooms (or be added)
CREATE POLICY "users can join rooms"
ON room_members FOR INSERT
WITH CHECK (auth.uid() = user_id);
-- Messages: users can read messages in rooms they're a member of
CREATE POLICY "members can read messages"
ON messages FOR SELECT
USING (
EXISTS (
SELECT 1 FROM room_members
WHERE room_members.room_id = messages.room_id
AND room_members.user_id = auth.uid()
)
);
-- Messages: users can send messages in rooms they're a member of
CREATE POLICY "members can send messages"
ON messages FOR INSERT
WITH CHECK (
auth.uid() = user_id
AND EXISTS (
SELECT 1 FROM room_members
WHERE room_members.room_id = messages.room_id
AND room_members.user_id = auth.uid()
)
);
Realtime broadcasts respect these policies. If a user is not a member of a room, they will not receive INSERT events for messages in that room — even if they hack their client to subscribe.
Step 3: Enable Realtime on the messages table#
ALTER PUBLICATION supabase_realtime ADD TABLE messages;
You can also do this in the dashboard: Database → Replication → supabase_realtime → toggle messages.
Without this step, your INSERTs go into Postgres but no one gets notified.
Step 4: Initial data load (server component)#
// app/rooms/[id]/page.tsx
import { createClient } from '@/lib/supabase/server'
import { ChatRoom } from './ChatRoom'
import { redirect, notFound } from 'next/navigation'
export default async function RoomPage({ params }: { params: Promise<{ id: string }> }) {
const { id: roomId } = await params
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) redirect('/login')
// RLS handles authorization — if user is not a member, this returns null
const { data: room } = await supabase
.from('rooms')
.select('id, name')
.eq('id', roomId)
.single()
if (!room) notFound()
// Initial message page (most recent 50)
const { data: messages } = await supabase
.from('messages')
.select('id, content, user_id, created_at')
.eq('room_id', roomId)
.order('created_at', { ascending: false })
.limit(50)
return (
<ChatRoom
room={room}
currentUserId={user.id}
initialMessages={messages?.reverse() ?? []}
/>
)
}
The .reverse() flips the descending query result back to ascending order so the UI renders oldest-to-newest.
Step 5: The chat client (with realtime subscription)#
// app/rooms/[id]/ChatRoom.tsx
'use client'
import { useEffect, useState, useRef } from 'react'
import { createClient } from '@/lib/supabase/browser'
import type { RealtimeChannel } from '@supabase/supabase-js'
type Message = {
id: string
content: string
user_id: string
created_at: string
}
export function ChatRoom({
room,
currentUserId,
initialMessages,
}: {
room: { id: string; name: string }
currentUserId: string
initialMessages: Message[]
}) {
const [messages, setMessages] = useState<Message[]>(initialMessages)
const [draft, setDraft] = useState('')
const supabase = createClient()
const channelRef = useRef<RealtimeChannel | null>(null)
useEffect(() => {
const channel = supabase.channel(`room:${room.id}`)
channel
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'messages',
filter: `room_id=eq.${room.id}`,
},
(payload) => {
setMessages((prev) => {
// De-dupe — message may already be in state from optimistic insert
if (prev.some((m) => m.id === payload.new.id)) return prev
return [...prev, payload.new as Message]
})
}
)
.subscribe()
channelRef.current = channel
return () => {
channel.unsubscribe()
}
}, [room.id, supabase])
async function sendMessage() {
if (!draft.trim()) return
const content = draft
setDraft('')
// Optimistic insert
const optimisticId = crypto.randomUUID()
const optimisticMessage: Message = {
id: optimisticId,
content,
user_id: currentUserId,
created_at: new Date().toISOString(),
}
setMessages((prev) => [...prev, optimisticMessage])
const { data, error } = await supabase
.from('messages')
.insert({ room_id: room.id, user_id: currentUserId, content })
.select()
.single()
if (error) {
// Roll back optimistic insert
setMessages((prev) => prev.filter((m) => m.id !== optimisticId))
alert('Message failed to send')
return
}
// Replace optimistic with real (in case the realtime event hasn't arrived yet)
setMessages((prev) =>
prev.map((m) => (m.id === optimisticId ? (data as Message) : m))
)
}
return (
<div>
<h1>{room.name}</h1>
<div className="messages">
{messages.map((m) => (
<div key={m.id}>
<strong>{m.user_id === currentUserId ? 'You' : m.user_id}:</strong> {m.content}
</div>
))}
</div>
<input
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && sendMessage()}
/>
<button onClick={sendMessage}>Send</button>
</div>
)
}
The de-dupe in the realtime handler is essential. Without it, every sent message appears twice — once from the optimistic insert and once from the realtime event.
Step 6: Pagination (infinite scrollback)#
Cursor-based, not offset:
async function loadOlderMessages() {
const oldest = messages[0]
if (!oldest) return
const { data } = await supabase
.from('messages')
.select('id, content, user_id, created_at')
.eq('room_id', room.id)
.lt('created_at', oldest.created_at)
.order('created_at', { ascending: false })
.limit(50)
if (data && data.length > 0) {
setMessages((prev) => [...data.reverse(), ...prev])
}
}
Wire this to a "Load more" button or to a scroll-position observer. The cursor (created_at of the oldest loaded message) means the page is stable even if new messages arrive while scrolling.
Step 7: Typing indicators (broadcast, not database)#
Database writes for typing indicators would create thousands of rows per chat. Use Supabase's broadcast channels instead:
// In ChatRoom component
const [typingUsers, setTypingUsers] = useState<string[]>([])
useEffect(() => {
const channel = channelRef.current
if (!channel) return
channel.on('broadcast', { event: 'typing' }, (payload) => {
const { user_id, is_typing } = payload.payload
setTypingUsers((prev) => {
if (is_typing) return [...new Set([...prev, user_id])]
return prev.filter((u) => u !== user_id)
})
})
}, [])
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null)
function handleTyping() {
channelRef.current?.send({
type: 'broadcast',
event: 'typing',
payload: { user_id: currentUserId, is_typing: true },
})
if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current)
typingTimeoutRef.current = setTimeout(() => {
channelRef.current?.send({
type: 'broadcast',
event: 'typing',
payload: { user_id: currentUserId, is_typing: false },
})
}, 3000)
}
// In the input handler:
// onChange={(e) => { setDraft(e.target.value); handleTyping(); }}
Broadcast events are ephemeral — they only reach currently-connected subscribers and are not persisted. Perfect for typing.
Step 8: Online presence#
useEffect(() => {
const channel = supabase.channel(`presence:${room.id}`, {
config: { presence: { key: currentUserId } },
})
channel
.on('presence', { event: 'sync' }, () => {
const state = channel.presenceState()
const onlineUsers = Object.keys(state)
console.log('Online users:', onlineUsers)
})
.on('presence', { event: 'join' }, ({ key }) => {
console.log('Joined:', key)
})
.on('presence', { event: 'leave' }, ({ key }) => {
console.log('Left:', key)
})
.subscribe(async (status) => {
if (status === 'SUBSCRIBED') {
await channel.track({ online_at: new Date().toISOString() })
}
})
return () => {
channel.unsubscribe()
}
}, [room.id, currentUserId, supabase])
Presence is automatic. The server detects disconnects within a few seconds (faster than any heartbeat job you would write yourself) and emits leave events.
Production gotchas#
1. Realtime does not fire if RLS rejects the row#
If the policy on messages does not return the row to the subscriber, the INSERT event is silently filtered. This is the right behavior for security but it looks like "realtime is broken" if your test user is not in the room.
Always test realtime with the actual user account, not the service role.
2. Channel cleanup on route changes#
Without unsubscribing in the useEffect cleanup, you will accumulate channels every time the user navigates between rooms. Eventually you hit the Realtime per-client connection limit (default 100). Symptoms: "channel error" and silent drops.
Always return the cleanup function from useEffect.
3. Realtime over a flaky network#
Connections drop. The Supabase client auto-reconnects, but you may miss INSERTs that happened during the gap. Two mitigations:
- On reconnect, query for messages with
created_at > last_known_message_created_atand merge them in. - Show a subtle "Reconnecting..." indicator so users know to refresh if it lasts.
channel.subscribe((status) => {
if (status === 'SUBSCRIBED') {
// Fetch any messages we may have missed
fetchMessagesSince(lastMessageTimestamp)
}
})
4. Message ordering under concurrent sends#
Two users sending at the same millisecond can produce out-of-order arrivals. The fix is to sort by created_at on every state update — never assume insertion order:
setMessages((prev) => {
const merged = [...prev, newMessage]
return merged.sort((a, b) => a.created_at.localeCompare(b.created_at))
})
5. Client connection scaling#
Each open Supabase Realtime channel is a connection. At thousands of concurrent users, you exhaust the per-project connection limit on Free or Pro.
Strategies:
- Channel multiplexing. One channel per room, not one per message type. Multiple
.on()listeners on the same channel cost no additional connection. - Disconnect inactive tabs. Detect
document.visibilityState === 'hidden'and unsubscribe. Resubscribe on focus. - Upgrade plan or split projects when you hit the limit on Pro.
6. Don't forget pagination on the server#
If a user joins a room with 100,000 messages, your initial server-side query needs .limit(50) or you will hang on TTFB. The cursor-based pagination from Step 6 covers loading older history.
Beyond the basics#
Where to take this app next:
- Read receipts. Add a
last_read_message_idcolumn toroom_members, update on visibility, broadcast on change. - Message edits/deletes. Extend the schema, broadcast UPDATE events too. Watch out for offline cases.
- File attachments. Use Supabase Storage; the Supabase Storage: Complete Guide to File Uploads and Management guide covers this.
- Reactions. Separate
reactionstable keyed bymessage_id. Realtime works the same way. - Mentions and notifications. Postgres triggers + a notifications table + a worker that sends push/email.
- End-to-end encryption. Out of scope for Supabase Realtime — you would encrypt before insert and decrypt on receipt, with key exchange handled separately.
See Also#
- Supabase Realtime: Complete Guide to Building Live Applications — broader Realtime feature reference
- Supabase Realtime Gotchas: 7 Issues and How to Fix Them — error-fix oriented
- Building Real-Time Collaboration Features with Next.js and Supabase — collaboration features (cursor sharing, etc)
- Supabase RLS Policy Design Patterns Beyond the Basics — advanced RLS for chat-style schemas
- Optimistic UI Patterns with Next.js Server Actions and Supabase Realtime — optimistic UI deep dive
- Scaling Next.js + Supabase from 0 to 100K Users: The Production Playbook — scaling Realtime under load
Closing#
A working chat app in 300 lines of code is the kind of thing Supabase makes feel easy. The hard parts are the ones in this guide — RLS that actually protects rooms, pagination that does not break on writes, broadcast vs database for ephemeral data, channel cleanup that does not leak.
Build the basic version this weekend. Layer on read receipts, presence, and reactions next week. By the end of two weekends you will have something that competes feature-for-feature with most paid chat platforms.
Then keep an eye on connection counts as you grow, and you will be ready for production.
Production Notes#
- Root cause to verify: test the failing query as the real
authenticatedoranonrole, not withservice_role. - Production fix pattern: keep policies explicit for
select,insert,update, anddelete, then add indexes for policy predicates. - Verification step: reproduce with one allowed user and one blocked user before shipping the policy change.
Frequently Asked Questions
One email a month — no fluff
RLS gotchas, Next.js cache debugging, and the one Supabase setting that bit me last month.
Related Guides
Supabase RLS Policy Design Patterns Beyond the Basics
Master advanced Supabase RLS policy patterns for multi-role access, team permissions, and hierarchical authorization. Includes copy-paste SQL and performance tips.
Supabase + Google OAuth on Next.js 15: The Complete Working Guide (2026)
A complete Google OAuth setup for Supabase + Next.js 15 (App Router, @supabase/ssr). Covers Cloud Console config, redirect URL allowlists, refresh tokens, scopes, prod vs dev, and the silent failures nobody warns you about.
Supabase Authentication & Authorization Patterns
Master Supabase authentication and authorization. Learn email/password auth, social logins, magic links, 2FA, row-level security policies, and role-based...