# How to Add Real-Time Chat to Your App (Copy-Paste Prompts Included)

- Tool: App Features
- Last updated: July 2026

## TL;DR

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.

## 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** (ui): A 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).
- **Message input** (ui): A 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.
- **Supabase Realtime broadcast channel** (backend): A `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.
- **Message persistence** (data): The `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`.
- **Channel and room management** (data): The `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.
- **Presence and online indicator** (backend): Supabase 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.
- **@mention parsing** (ui): A 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.

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

```sql
create table public.chat_rooms (
  id uuid primary key default gen_random_uuid(),
  name text not null,
  slug text not null unique,
  is_public bool not null default true,
  created_at timestamptz not null default now()
);

create table public.chat_messages (
  id uuid primary key default gen_random_uuid(),
  room_id uuid not null references public.chat_rooms(id) on delete cascade,
  user_id uuid not null references auth.users(id) on delete cascade,
  body text not null check (char_length(body) <= 4000),
  reactions jsonb not null default '{}',
  created_at timestamptz not null default now()
);

create table public.room_members (
  room_id uuid not null references public.chat_rooms(id) on delete cascade,
  user_id uuid not null references auth.users(id) on delete cascade,
  role text not null default 'member' check (role in ('member', 'moderator')),
  joined_at timestamptz not null default now(),
  primary key (room_id, user_id)
);

-- Indexes for performance
create index chat_messages_room_created_idx
  on public.chat_messages (room_id, created_at desc);

create index room_members_user_idx
  on public.room_members (user_id);

-- Enable RLS
alter table public.chat_rooms enable row level security;
alter table public.chat_messages enable row level security;
alter table public.room_members enable row level security;

-- Chat rooms: anyone authenticated can read public rooms
create policy "public rooms readable"
  on public.chat_rooms for select
  using (is_public = true or id in (
    select room_id from public.room_members where user_id = auth.uid()
  ));

-- Messages: readable in public rooms or by members
create policy "messages readable by room participants"
  on public.chat_messages for select
  using (
    room_id in (
      select id from public.chat_rooms where is_public = true
    )
    or room_id in (
      select room_id from public.room_members where user_id = auth.uid()
    )
  );

-- Messages: only members can insert
create policy "members can send messages"
  on public.chat_messages for insert
  with check (
    user_id = auth.uid()
    and room_id in (
      select room_id from public.room_members where user_id = auth.uid()
    )
  );

-- Messages: sender can update reactions
create policy "participants can update reactions"
  on public.chat_messages for update
  using (
    room_id in (
      select room_id from public.room_members where user_id = auth.uid()
    )
  );

-- Room members: users can see who is in rooms they belong to
create policy "members visible to room participants"
  on public.room_members for select
  using (
    room_id in (
      select room_id from public.room_members where user_id = auth.uid()
    )
  );

-- Room members: users can join public rooms
create policy "users can join public rooms"
  on public.room_members for insert
  with check (
    user_id = auth.uid()
    and room_id in (select id from public.chat_rooms where is_public = true)
  );
```

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 paths

### Lovable — fit 5/10, 3–5 hours

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.

1. Create a new Lovable project with Lovable Cloud connected — this provisions the Supabase project and auth automatically
2. Paste the prompt below in Agent Mode and let it scaffold the chat room page, message list, input, and Realtime subscription
3. After generation, open the Lovable preview in two separate browser tabs logged in as two different users to verify real-time delivery
4. Run the SQL from the af_data_model section in the Supabase SQL editor if the AI-generated schema is missing indexes or RLS policies
5. Publish to HTTPS and test @mention autocomplete and Presence online count with two real users

Starter prompt:

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

Limitations:

- 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

### V0 — fit 4/10, 4–6 hours

Strong fit for Next.js projects — clean App Router client component structure with Supabase subscription is well-supported. Supabase is not auto-provisioned; env vars and schema must be set up manually.

1. Prompt v0 with the spec below to generate the ChatRoom client component and message list
2. Add NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY in the v0 Vars panel
3. Run the SQL schema from the af_data_model section above in the Supabase SQL editor
4. Publish to Vercel and test real-time delivery with two browser tabs on the live URL

Starter prompt:

```
Build a real-time chat room as a Next.js App Router feature. Create a `ChatRoom` client component ('use client') that: subscribes to Supabase `postgres_changes` on chat_messages INSERT filtered by room_id; auto-scrolls to a bottomRef div on new messages; shows a 'X new messages' button when user has scrolled up; loads the last 50 messages on mount with cursor-based pagination (WHERE created_at < cursor ORDER BY created_at DESC LIMIT 50). Create a `MessageInput` client component with: shadcn/ui Input, emoji-mart v5 picker in a Popover, @mention autocomplete that queries room_members by typed prefix, sends message on Enter (Shift+Enter = newline). Add a `RoomHeader` server component showing room name; make the online count a client component using Supabase Realtime Presence (channel.track, usePresence pattern). Add emoji reactions on messages: jsonb column, optimistic update with SWR mutate, rollback on error. Add join/leave system messages. RLS must allow SELECT for public rooms and restrict INSERT to room_members. Wire Supabase env vars from NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY.
```

Limitations:

- v0 does not provision Supabase — you must run the schema SQL manually and add env vars in the Vercel dashboard
- The v0 preview sandbox cannot maintain Supabase Realtime subscriptions — deploy to Vercel to test real-time delivery
- Server Components and client components must be explicitly split; the AI occasionally puts Supabase subscriptions in a Server Component — look for the 'use client' directive on the ChatRoom component

### Flutterflow — fit 4/10, 1–2 days

Excellent for mobile-first apps — Firebase Firestore real-time streams map naturally to a chat room and FlutterFlow's StreamBuilder widget handles live updates without custom code. @mention autocomplete requires a custom Dart widget.

1. Add a Firestore collection `chatMessages` with fields: roomId (String), userId (String), body (String), createdAt (Timestamp), reactions (Map of String to Array)
2. Create a Page in FlutterFlow with a StreamBuilder widget querying `chatMessages` where `roomId == currentRoomId`, ordered by `createdAt` ascending
3. Add a ListView inside the StreamBuilder; set 'Shrink Wrap' to true and 'Reverse' to true so newest messages appear at the bottom
4. Add a TextFormField at the bottom for message input; wire an 'Add Document' action on submit to insert into the Firestore collection
5. Add Firestore Security Rules: `allow read: if request.auth != null; allow write: if request.auth.uid == request.resource.data.userId`

Limitations:

- @mention autocomplete requires a custom Dart widget — FlutterFlow's built-in text field does not support overlay suggestions triggered by specific characters
- Emoji reactions stored as a Firestore Map with array values require a custom Dart action for atomic updates (Firestore `arrayUnion` / `arrayRemove`)
- The Presence online indicator needs a custom Dart action using Firebase Realtime Database's `onDisconnect().remove()` pattern — not available in FlutterFlow's visual action editor

### Custom — fit 2/10, 1–3 weeks

Only warranted for guaranteed message ordering with acknowledgement receipts, E2E encryption, more than 500 concurrent users per room, or compliance archiving with legal hold. Socket.io + Redis Pub/Sub + PostgreSQL is the canonical stack for these requirements.

1. Socket.io on Node.js with Redis Pub/Sub for fan-out handles >1K concurrent users per room — Supabase Realtime peaks at 500 connections per project on Pro
2. E2E encryption requires a key exchange protocol (Signal Protocol or libsodium) before messages are transmitted — the server stores only ciphertext
3. Compliance archiving needs a write-once append log (e.g., Kafka or a dedicated audit table with NO DELETE RLS policy) separate from the operational messages table
4. Message ordering guarantees need a vector clock or server-assigned sequence number — Supabase `created_at` timestamps are not strictly monotonic under concurrent load

Limitations:

- Custom WebSocket infrastructure requires ongoing ops work — server restarts, connection balancing, Redis memory management
- Justified only when Supabase Realtime's 500-connection limit or broadcast reliability is a real constraint for your user count

## Gotchas

- **Broadcast messages lost on disconnect** — 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** — 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** — 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** — 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** — 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

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

---

Source: https://www.rapidevelopers.com/app-features/real-time-chat
© RapidDev — https://www.rapidevelopers.com/app-features/real-time-chat
