Feature spec
IntermediateCategory
communication-social
Build with AI
3–5 hours with Lovable or v0
Custom build
1–3 weeks custom dev
Running cost
$0–$25/mo up to 1K users
Works on
Everything it takes to ship Real-Time Chat — parts, prompts, and real costs.
Real-time chat needs a message persistence layer, a Supabase Realtime subscription for live delivery, a presence channel for online indicators, and a virtualised list for history. With Lovable or v0 you can ship a working chat room in 3–5 hours for $0/month on the free tier. The only meaningful cost threshold is Supabase Pro ($25/mo) when concurrent active chatters exceed 200 connections.
What Real-Time Chat Actually Is
Real-time chat is a live open-channel room where all participants see new messages appear within milliseconds of sending — no refresh required. Unlike in-app messaging (which is private DM threads), real-time chat is typically a named room or channel that multiple users join simultaneously. The distinguishing technical choice is Supabase Realtime: you can use broadcast mode (ephemeral, lowest latency, messages not stored) or postgres_changes mode (messages persisted to the database and delivered via Realtime when inserted). Most production apps use postgres_changes for history persistence and broadcast for ultra-low-latency side channels. The rest of the feature — @mention autocomplete, emoji reactions, presence indicators — is UX polish built on top of these two modes.
What users consider table stakes in 2026
- Messages appear for all participants within 500 ms of sending — no manual refresh or polling visible to the user
- Auto-scroll to the newest message when a new one arrives, with a 'scroll to bottom' button that appears when the user is reading history
- Timestamps on every message in a human-readable relative format (e.g., '2 min ago') that updates passively
- @mention autocomplete opens a filtered user list popover when the user types @ and selects a name to tag
- Online user count shown in the room header via Supabase Realtime Presence, updating live as users join and leave
- System join/leave messages when a user enters or exits the room, distinct from regular messages
- Empty room state with a share link so the first user can invite others before any messages exist
Anatomy of the Feature
Seven components — Lovable scaffolds the core list, input, and Supabase subscription correctly in one prompt. The @mention popover, presence online count, and broadcast vs postgres_changes distinction are where first builds consistently need a follow-up prompt.
Chat room component
UIA react-virtuoso virtualised list rendering `ChatMessage` items. Maintains a `bottomRef` div target and auto-scrolls on new messages unless the user has scrolled up to read history. Supports loading older messages by scrolling to the top (cursor-based pagination).
Note: Do not use a plain `<div>` with overflow-y scroll for message lists that may grow to hundreds of items — DOM rendering of 500+ message nodes causes visible jank. react-virtuoso renders only visible rows.
Message input
UIA shadcn/ui Input component with an emoji-mart v5 picker in a Popover, and an @mention autocomplete that queries `room_members` when the user types `@`. Submits on Enter, Shift+Enter for newline. Fires the Supabase INSERT and clears on successful send.
Note: Track the `isTyping` state and call `channel.track({ typing: true })` on keypress, then `channel.track({ typing: false })` 1 second after the last keystroke using a debounce — not on every keypress.
Supabase Realtime broadcast channel
BackendA `supabase.channel('room:{roomId}').on('broadcast', { event: 'message' }, handler).subscribe()` subscription for ephemeral low-latency delivery. Broadcast does not persist messages — it is ideal for typing indicators and ephemeral events.
Note: Broadcast and postgres_changes are complementary, not mutually exclusive. Use broadcast for typing state; use postgres_changes for message persistence delivery so history survives page refreshes.
Message persistence
DataThe `chat_messages` table in Supabase PostgreSQL. A `postgres_changes` subscription on INSERT delivers persisted messages to all connected clients. The `reactions` jsonb column stores emoji reaction maps `{ '❤️': ['user-uuid'] }`. Cursor-based pagination loads older history: `WHERE created_at < :cursor ORDER BY created_at DESC LIMIT 30`.
Note: Sort the in-memory message array by `created_at` after every Realtime update — events occasionally arrive microseconds out of order under load.
Channel and room management
DataThe `chat_rooms` table stores named channels with a slug for URL routing. `room_members` tracks membership with a `role` column (`member` | `moderator`). Public rooms allow any authenticated user to read and post; private rooms restrict INSERT to room members only via RLS.
Note: Auto-insert a `room_members` row when a user sends their first message in a public room — without this, the RLS INSERT policy blocks them even though the room is public.
Presence and online indicator
BackendSupabase Realtime Presence `channel.track({ user_id, display_name, avatar_url })` reports who is currently connected to the room channel. The room header displays the live count using `usePresence()` or `channel.presenceState()`. Presence is per-channel and clears automatically when the connection closes.
Note: Presence state is eventually consistent — it takes 1–2 seconds to synchronise after a user joins. Show a loading skeleton in the online count briefly on mount.
@mention parsing
UIA regex `/@(\w+)/g` applied to the message body on render. Matched usernames are wrapped in a highlighted `<span>` with a link to the user's profile. The autocomplete popover queries `room_members` filtered by the typed prefix to suggest names.
Note: Anchor the @mention popover to the bottom of the input on mobile — the keyboard shifts the viewport and a relative-positioned popover jumps unpredictably.
The data model
Three tables cover rooms, messages, and membership. Run this in the Supabase SQL editor — it includes RLS policies that allow public rooms while restricting message INSERT to members only:
1create table public.chat_rooms (2 id uuid primary key default gen_random_uuid(),3 name text not null,4 slug text not null unique,5 is_public bool not null default true,6 created_at timestamptz not null default now()7);89create table public.chat_messages (10 id uuid primary key default gen_random_uuid(),11 room_id uuid not null references public.chat_rooms(id) on delete cascade,12 user_id uuid not null references auth.users(id) on delete cascade,13 body text not null check (char_length(body) <= 4000),14 reactions jsonb not null default '{}',15 created_at timestamptz not null default now()16);1718create table public.room_members (19 room_id uuid not null references public.chat_rooms(id) on delete cascade,20 user_id uuid not null references auth.users(id) on delete cascade,21 role text not null default 'member' check (role in ('member', 'moderator')),22 joined_at timestamptz not null default now(),23 primary key (room_id, user_id)24);2526-- Indexes for performance27create index chat_messages_room_created_idx28 on public.chat_messages (room_id, created_at desc);2930create index room_members_user_idx31 on public.room_members (user_id);3233-- Enable RLS34alter table public.chat_rooms enable row level security;35alter table public.chat_messages enable row level security;36alter table public.room_members enable row level security;3738-- Chat rooms: anyone authenticated can read public rooms39create policy "public rooms readable"40 on public.chat_rooms for select41 using (is_public = true or id in (42 select room_id from public.room_members where user_id = auth.uid()43 ));4445-- Messages: readable in public rooms or by members46create policy "messages readable by room participants"47 on public.chat_messages for select48 using (49 room_id in (50 select id from public.chat_rooms where is_public = true51 )52 or room_id in (53 select room_id from public.room_members where user_id = auth.uid()54 )55 );5657-- Messages: only members can insert58create policy "members can send messages"59 on public.chat_messages for insert60 with check (61 user_id = auth.uid()62 and room_id in (63 select room_id from public.room_members where user_id = auth.uid()64 )65 );6667-- Messages: sender can update reactions68create policy "participants can update reactions"69 on public.chat_messages for update70 using (71 room_id in (72 select room_id from public.room_members where user_id = auth.uid()73 )74 );7576-- Room members: users can see who is in rooms they belong to77create policy "members visible to room participants"78 on public.room_members for select79 using (80 room_id in (81 select room_id from public.room_members where user_id = auth.uid()82 )83 );8485-- Room members: users can join public rooms86create policy "users can join public rooms"87 on public.room_members for insert88 with check (89 user_id = auth.uid()90 and room_id in (select id from public.chat_rooms where is_public = true)91 );Heads up: The `chat_messages_room_created_idx` composite index keeps cursor-based history pagination fast even at thousands of messages per room. The `room_members` auto-join policy for public rooms is critical — without it, the send-message RLS policy blocks users from posting until they are explicitly added as members.
Build it — pick your path
Each tool fits this feature differently. Switch tabs to compare fit, time, the exact steps, and a copy-paste prompt.
Best all-round path: Supabase Realtime is auto-wired, auth is included, and Lovable scaffolds the full chat room UI in one prompt. @mention autocomplete and the broadcast vs postgres_changes distinction need explicit instruction.
Step by step
- 1Create a new Lovable project with Lovable Cloud connected — this provisions the Supabase project and auth automatically
- 2Paste the prompt below in Agent Mode and let it scaffold the chat room page, message list, input, and Realtime subscription
- 3After generation, open the Lovable preview in two separate browser tabs logged in as two different users to verify real-time delivery
- 4Run the SQL from the af_data_model section in the Supabase SQL editor if the AI-generated schema is missing indexes or RLS policies
- 5Publish to HTTPS and test @mention autocomplete and Presence online count with two real users
Build a real-time chat room feature on Supabase. Create three tables: `chat_rooms` (id uuid pk, name text, slug text unique, is_public bool default true, created_at timestamptz), `chat_messages` (id uuid pk, room_id uuid references chat_rooms, user_id uuid references auth.users, body text, reactions jsonb default '{}', created_at timestamptz default now()), `room_members` (room_id uuid, user_id uuid, role text default 'member', joined_at timestamptz, PRIMARY KEY(room_id, user_id)). Use `postgres_changes` subscription on chat_messages INSERT for persistence (not broadcast-only). Add a Supabase Realtime Presence channel per room to show online user count in the room header. Auto-insert a room_members row when a user first enters a public room before subscribing. Add a virtualised message list that auto-scrolls to the newest message; show a 'scroll to bottom' button when user scrolls up. Sort incoming Realtime messages by created_at after each update. Add a message input with: emoji picker using emoji-mart v5, @mention autocomplete that queries room_members filtered by typed prefix and renders suggestions in a Popover anchored below the input. Send join/leave system messages when users enter or leave the room. Add emoji reactions on messages using jsonb with optimistic update. Include an empty room state with a copyable invite link. RLS: public rooms readable by all authenticated users; INSERT into chat_messages requires room membership. Handle the case where a user is not yet in room_members by auto-joining on first visit to a public room.Where this path bites
- The Lovable preview shares a single Supabase connection — open the published URL in two tabs for true real-time testing
- @mention autocomplete is often generated without mobile keyboard awareness; the popover may position incorrectly on phones — test on the published URL
- AI may conflate broadcast and postgres_changes modes; verify in the generated code that messages are inserted to the DB, not just broadcast ephemerally
Third-party services you'll need
Real-time chat on Supabase requires no third-party chat SDK. The only billable service is Supabase itself:
| Service | What it does | Free tier | Paid from |
|---|---|---|---|
| Supabase | PostgreSQL message storage, Realtime subscriptions (postgres_changes + broadcast + Presence), and Auth | 200 concurrent Realtime connections, 500 MB DB, 2 GB bandwidth | Pro $25/mo: 500 connections, 8 GB DB, 250 GB bandwidth |
Swipe the table sideways to see pricing.
What it costs to run
Drag through the tiers to see how your monthly bill scales with users — no surprises later.
Estimated monthly running cost
Supabase free tier: 200 concurrent Realtime connections is ample for 100 active chatters. Message storage at typical chat volume stays well under 500 MB.
Estimates use verified 2026 pricing and assume typical usage per user. Your real bill depends on activity, storage, and third-party plans.
What breaks when AI tools build this
The failures people actually hit on their first build — the symptom, why it happens, and the exact fix.
Broadcast messages lost on disconnect
Symptom: Using Supabase Realtime broadcast mode means messages are not stored in the database — they are ephemeral, fire-and-forget events. A user who refreshes the page or reconnects after a brief disconnect sees an empty message history, and any message sent during the disconnect is permanently lost.
Fix: Use `postgres_changes` subscription mode for message delivery so every message is persisted to the `chat_messages` table first. Use broadcast only for ephemeral side channels like typing indicators. Alternatively, insert to the DB on send and subscribe to postgres_changes INSERT events to deliver to other clients.
New users blocked from sending messages in public rooms
Symptom: The RLS INSERT policy for `chat_messages` correctly requires `auth.uid() IN (SELECT user_id FROM room_members WHERE room_id = ...)`. But AI tools forget to auto-insert a `room_members` row when a user first visits a public room. Every new user gets a silent 'permission denied' error on their first send.
Fix: Auto-upsert a `room_members` row when the user navigates to a public room — before subscribing to the Realtime channel. Use `supabase.from('room_members').upsert({ room_id, user_id: currentUser.id }, { onConflict: 'room_id,user_id' })` on room entry.
@mention popover flickers on mobile
Symptom: Opening the @mention suggestion list on `@` keypress triggers the mobile keyboard to reposition the viewport. A Popover with relative positioning jumps or disappears entirely because its calculated position is stale after the keyboard animation.
Fix: Render the mention suggestion list with `position: fixed` anchored to a fixed distance above the message input bottom edge. Detect mobile viewport (`window.innerWidth < 768`) and apply a fixed top offset equal to `window.innerHeight - keyboardHeight - listHeight`. Alternatively, use a bottom sheet drawer on mobile instead of a floating popover.
Messages render out of order
Symptom: Supabase Realtime postgres_changes events for two messages inserted within the same millisecond occasionally arrive in swapped order. On a busy channel with multiple concurrent senders, 1–5 messages per hour may appear out of sequence.
Fix: After every Realtime INSERT event, sort the in-memory message array by `created_at` ascending: `setMessages(prev => [...prev, newMsg].sort((a, b) => new Date(a.created_at) - new Date(b.created_at)))`. This costs negligible compute for typical chat volumes.
Supabase free tier connection limit hit silently
Symptom: The Supabase free tier allows 200 concurrent Realtime connections across the entire project — not per chat room. When this limit is exceeded, new connections are silently dropped: the subscribe() call returns no error, but messages stop arriving. Founders discover this when users report chat 'going quiet' with no error logs.
Fix: Monitor concurrent Realtime connections in the Supabase dashboard under Realtime → Inspector. Set up an alert when connections approach 180. Upgrade to Supabase Pro (500 connections) before launch, or implement channel multiplexing where one WebSocket connection handles multiple room subscriptions.
Best practices
Use `postgres_changes` subscriptions for message delivery (not broadcast-only) so history survives reconnects, page refreshes, and new user joins
Auto-upsert a `room_members` row on every public room entry before subscribing — the INSERT RLS policy will silently block first-time senders otherwise
Sort the in-memory message array by `created_at` after every Realtime update event, not just on initial load — events can arrive microseconds out of order
Use a virtualised list (react-virtuoso) for the message history — a plain scrollable div with 500+ DOM nodes causes visible frame drops on mid-range devices
Monitor Supabase Realtime concurrent connections before launch — the 200-connection free tier limit affects all rooms in the project simultaneously
Anchor the @mention suggestion popover with `position: fixed` on mobile — relative-positioned popovers jump when the keyboard shifts the viewport
Use cursor-based pagination for message history (`WHERE created_at < :cursor`) not offset — offset pagination causes duplicate messages when new messages are inserted during scroll
Gate the typing indicator Presence track call with a 1-second debounce — calling `channel.track()` on every keypress saturates the Presence channel
When You Need Custom Development
Supabase Realtime covers most chat use cases up to a few hundred concurrent users per project. Four scenarios outgrow it:
- You need more than 500 concurrent users active in chat simultaneously — Supabase Pro's connection limit requires Socket.io + Redis Pub/Sub for fan-out at that scale
- You need end-to-end encryption where the server cannot read message content — requires Signal Protocol or libsodium key exchange before any message reaches Supabase
- You need guaranteed message delivery with acknowledgement receipts (MQTT QoS level 1 or 2) for mission-critical communications
- You need compliance message archiving with legal hold — a write-once immutable audit log separate from the operational chat table
RapidDev builds these features for production
Real auth, database, and payments — fixed price, delivered in weeks. You own 100% of the code.
Frequently asked questions
What is the best way to build real-time chat with Supabase?
Use `postgres_changes` mode (not broadcast-only) so every message is persisted to the `chat_messages` table before delivery. Subscribe with `supabase.channel('room:' + roomId).on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'chat_messages', filter: 'room_id=eq.' + roomId }, handler).subscribe()`. This guarantees history survives reconnects. Use broadcast only for ephemeral events like typing indicators where persistence is not needed.
What is the difference between Supabase Realtime broadcast and postgres_changes?
Broadcast is ephemeral: messages are sent directly between connected clients and never written to the database. If a user disconnects and reconnects, they miss everything sent during the gap. Postgres_changes subscribes to database INSERT/UPDATE/DELETE events — every message is persisted first, so history is always retrievable. Use postgres_changes for chat messages and broadcast for typing indicators.
How do I add @mention to a chat room?
On `@` keypress, open a popover and query `room_members` filtered by the string typed after `@`. Render matched users as clickable suggestions that insert the full `@username` into the message input. On the display side, apply a regex `/@(\w+)/g` to the message body and wrap matches in a highlighted span. On mobile, use `position: fixed` for the suggestion list — a relative-positioned popover jumps when the keyboard opens.
How many users can be in a chat room at once?
Supabase free tier supports 200 concurrent Realtime connections per project (across all rooms). Supabase Pro supports 500. This means 200–500 simultaneously active chatters before you need to upgrade or implement connection multiplexing. If your use case needs thousands of concurrent users in a single room, you need a custom WebSocket layer with Redis Pub/Sub for fan-out.
How do I load older messages when a user scrolls up?
Use keyset (cursor) pagination, not offset. Store the `created_at` timestamp of the oldest loaded message as a cursor. When the user scrolls to the top, fetch `WHERE created_at < :cursor ORDER BY created_at DESC LIMIT 30` and prepend the results. Never use `OFFSET N` — concurrent message inserts shift rows and cause duplicates to appear.
Do I need a paid plan to add real-time chat?
Not initially. Supabase's free tier allows 200 concurrent Realtime connections, which supports a small-to-medium app. You will need Supabase Pro ($25/mo) once your simultaneous active user count consistently approaches 180–200. For context: 1,000 registered users with a 20% concurrent activity rate means 200 simultaneous connections — right at the free tier limit.
How do I show who is online in a chat room?
Use Supabase Realtime Presence. Call `channel.track({ user_id: currentUser.id, display_name: currentUser.name })` when the user joins the room. Subscribe to `channel.on('presence', { event: 'sync' }, () => { const state = channel.presenceState(); setOnlineUsers(Object.values(state).flat()); })`. Presence state clears automatically when the user closes the tab or disconnects.
Can I use Supabase real-time chat in a FlutterFlow app?
Yes, but the native path in FlutterFlow is Firebase Firestore with a StreamBuilder widget — it requires less custom code than wiring Supabase's JavaScript SDK in a Dart environment. If your project is already on Supabase, you can use supabase-flutter package via a custom action, but the Firestore path is faster to build in FlutterFlow's visual editor.
Need this feature production-ready?
RapidDev builds real-time chat into real apps — auth, database, payments — at $13K–$25K.
Book a free consultation30-min call. No commitment.