Build a Real-Time Chat App with Next.js 15 + Supabase: The Complete Production Build
Developer Guide

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.

2026-04-20
34 min read
Build a Real-Time Chat App with Next.js 15 + Supabase: The Complete Production Build

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/ssr and @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.

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.

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

sql
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)#

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({ 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:

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.

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. 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_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: Complete Guide to File Uploads and Management 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.

See Also#

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 authenticated or anon role, not with service_role.
  • Production fix pattern: keep policies explicit for select, insert, update, and delete, 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

|

Have more questions? Contact us

One email a month — no fluff

RLS gotchas, Next.js cache debugging, and the one Supabase setting that bit me last month.