Developer Guide

Build a Real-Time Chat App: Next.js + Supabase Production

A complete real-time chat app with Next.js 15 + Supabase: schema, RLS policies, presence, typing indicators, pagination, and production gotchas.

Mahdi Ben RhoumaMahdi Ben Rhouma
Updated
34 min read

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/ssr and @supabase/supabase-js
  • TypeScript

Prior knowledge: you can read the Supabase Auth + Middleware: Session 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.

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

Every membership check reads room_members. A policy on room_members that queries room_members again cannot resolve: Postgres raises 42P17, infinite recursion detected in policy for relation. Because the rooms and messages policies also query room_members, the error would break every room and message read. The Supabase RLS docs break the cycle with a security definer function in a schema that is not exposed through the Data API:

sql
ALTER TABLE rooms ENABLE ROW LEVEL SECURITY;
ALTER TABLE room_members ENABLE ROW LEVEL SECURITY;
ALTER TABLE messages ENABLE ROW LEVEL SECURITY;
 
-- Room ids the current user belongs to. Runs as its owner, so it skips
-- RLS on room_members and the policies below do not recurse.
CREATE SCHEMA IF NOT EXISTS private;
 
CREATE FUNCTION private.user_room_ids()
RETURNS SETOF uuid
LANGUAGE sql
SECURITY DEFINER
SET search_path = ''
STABLE
AS $$
  SELECT room_id FROM public.room_members
  WHERE user_id = (SELECT auth.uid())
$$;
 
REVOKE EXECUTE ON FUNCTION private.user_room_ids() FROM public;
GRANT USAGE ON SCHEMA private TO authenticated;
GRANT EXECUTE ON FUNCTION private.user_room_ids() TO authenticated;
 
-- Rooms: members can read their rooms; the creator can read the room they made
CREATE POLICY "members can read rooms"
  ON rooms FOR SELECT TO authenticated
  USING (
    id IN (SELECT private.user_room_ids())
    OR created_by = (SELECT auth.uid())
  );
 
-- Rooms: any authenticated user can create a room they own
CREATE POLICY "users can create rooms"
  ON rooms FOR INSERT TO authenticated
  WITH CHECK ((SELECT 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 TO authenticated
  USING (room_id IN (SELECT private.user_room_ids()));
 
-- Room members: only the room's creator can add members (including themselves)
CREATE POLICY "room owner can add members"
  ON room_members FOR INSERT TO authenticated
  WITH CHECK (
    EXISTS (
      SELECT 1 FROM rooms
      WHERE rooms.id = room_members.room_id
      AND rooms.created_by = (SELECT auth.uid())
    )
  );
 
-- Messages: users can read messages in rooms they're a member of
CREATE POLICY "members can read messages"
  ON messages FOR SELECT TO authenticated
  USING (room_id IN (SELECT private.user_room_ids()));
 
-- Messages: users can send messages in rooms they're a member of
CREATE POLICY "members can send messages"
  ON messages FOR INSERT TO authenticated
  WITH CHECK (
    (SELECT auth.uid()) = user_id
    AND room_id IN (SELECT private.user_room_ids())
  );

A policy that only checks auth.uid() = user_id on room_members INSERT would let any signed-in user add themselves to any room id and then read it, so the insert is gated on room ownership instead. The rooms SELECT policy includes created_by so the owner can see the room before adding the first member row. Invitations or public rooms need their own, equally explicit policy.

Postgres Changes events respect these policies: the Postgres Changes docs state that it "authorizes every event against each subscriber". If a user is not a member of a room, they will not receive INSERT events for messages in that room, even if they modify their client to subscribe. Broadcast and Presence are a different matter: on a public channel they are not restricted by table RLS at all (see Steps 7 and 8).

Step 3: Enable Realtime on the messages table#

Per the Supabase Postgres Changes docs, you enable change events for a table by adding it to the supabase_realtime publication:

sql
ALTER PUBLICATION supabase_realtime ADD TABLE messages;

You can also do this in the dashboard under Database → Publications (supabase_realtime), as the same docs describe.

Without this step, your INSERTs go into Postgres but no one gets notified.

Step 4: Initial data load (server component)#

typescript
// 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)#

typescript
// 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({ id: optimisticId, 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 insert reuses the optimistic id as the row's primary key, so the optimistic entry, the insert response and the realtime event all share one id. That is what makes the de-dupe in the realtime handler work: if the database generated its own id, the realtime event could arrive before the insert response and the message would appear twice.

Step 6: Pagination (infinite scrollback)#

Cursor-based, not offset:

typescript
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:

typescript
// 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.

On a public channel, the Realtime Authorization docs state that Broadcast and Presence are not restricted by RLS: any connected client that knows the topic room:<id> can listen to typing events and track presence. To limit them to members, create the channel with config: { private: true }, add a SELECT (and, for sending, INSERT) policy on realtime.messages that compares realtime.topic() with the user's rooms, and disable "Allow public access" in the Realtime settings.

Step 8: Online presence#

typescript
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: clients that leave cleanly trigger a leave event, and dropped sockets are detected through the heartbeat (realtime-js defaults to one every 25 seconds), so a leave can lag behind an abrupt disconnect. No heartbeat job of your own is needed.

This example opens a second channel per room for presence, which counts against the same channel quota as the message channel (see gotcha 5). Calling .on('presence', ...) and track() on the existing room: channel avoids that; both need the private-channel setup above if presence should stay inside the room.

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 limit of 100 channels per connection (Realtime limits). 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_at and merge them in.
  • Show a subtle "Reconnecting..." indicator so users know to refresh if it lasts.
typescript
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:

typescript
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_id column to room_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 File Uploads in Next.js guide covers this.
  • Reactions. Separate reactions table keyed by message_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.

Closing#

The hard parts of a Supabase chat app are the ones in this guide: RLS that protects rooms without recursing, private channels for typing and presence, pagination that does not break on writes, broadcast instead of database writes for ephemeral data, and channel cleanup that does not leak.

Once the basic version works, add read receipts and reactions, and keep an eye on concurrent connections against your plan's Realtime quota as you grow.

Frequently Asked Questions

|

Have more questions? Contact us

Still stuck? Paste your error.

Paste the error message or stack trace — we check it against every fix and tool on the site.

Matching happens in your browser. When you search, a cleaned error signature is sent so we can spot errors we do not cover yet — never the raw text, your IP address or your e-mail. Do not paste passwords or keys. Details

One email a month — the latest explained errors

What the site published that month — mostly Next.js, Supabase and Node errors, explained. You confirm by email; unsubscribe any time in one click.