Feature spec
IntermediateCategory
communication-social
Build with AI
4-8 hours with Lovable or v0
Custom build
2-4 weeks custom dev
Running cost
$0/mo up to 200 concurrent users, $25/mo on Supabase Pro
Works on
Everything it takes to ship In-App Messaging — parts, prompts, and real costs.
In-app messaging needs a Supabase Realtime channel for live delivery, a conversations + messages table with RLS locking participants to their own threads, and a push trigger via OneSignal or FCM when recipients are offline. With Lovable you can ship working 1-to-1 and group DMs in 4-8 hours for $0/mo up to ~200 concurrent users, then $25/mo on Supabase Pro.
What In-App Messaging Actually Is
In-app messaging is the private, persistent conversation layer inside your product — the DMs on LinkedIn, the order chat on Airbnb, the support thread on Intercom. It is distinct from a live public chat room: conversations are addressed to specific participants, history persists across sessions and devices, and unread badges guide users back. The architecture involves three pieces working together: a Supabase Realtime channel for sub-second delivery, a PostgreSQL schema that lets each participant read exactly their own conversations, and a push notification trigger for users who are no longer in the app. Getting the real-time and the RLS right is where first builds stall.
What users consider table stakes in 2026
- Real-time message delivery with no visible lag — users expect messages to appear within 500 ms
- Read receipts and a 'delivered' indicator so senders know their message arrived
- Persistent message history that loads instantly on re-open, even after days away
- Typing indicator that appears and disappears as the other person types
- Image and file attachment support alongside plain text
- Push notification for new messages when the recipient has left the app or closed the browser
Anatomy of the Feature
Seven components make up a production messaging system. AI tools scaffold the UI halves reliably in one prompt — the Supabase Realtime subscription cleanup and the RLS policies are where builds fail.
Conversation list panel
UIShows all active conversations for the current user, sorted by most recent message. Each row displays the other participant's avatar (shadcn/ui Avatar), a preview of the last message body, an unread count badge, and a relative timestamp.
Note: Built with shadcn/ui ScrollArea to keep the panel scrollable at any list length. Unread count is computed from conversation_participants.last_read_at vs. the latest message created_at.
Message thread view
UIA virtualised list of message bubbles for the active conversation. Sender messages align right, recipient messages left. react-virtuoso handles large histories without freezing — rendering 1,000 messages in a plain div map will lock the browser.
Note: react-window is the lighter alternative if you never expect more than a few hundred messages. Always anchor scroll to the bottom on new messages with a ref on the last item.
Message input composer
UIA rich input built on Tiptap or a plain textarea. Includes an emoji picker (emoji-mart v5) triggered via a shadcn/ui Popover, and a file/image attachment trigger that opens a hidden file input. Pressing Enter sends; Shift+Enter inserts a newline.
Note: Tiptap is the better choice when you need bold/italic or @mentions. For simple text-only messaging, a plain textarea with autosize is smaller and faster to prompt.
Supabase Realtime channel
BackendUses supabase-js channel().on('postgres_changes') subscribed to the messages table filtered by conversation_id. New message rows appear in the thread instantly for all active participants without any polling.
Note: Subscribe inside a React useEffect and return channel.unsubscribe() from the cleanup function. Missing this cleanup creates multiple active subscriptions on hot-reload, causing every message to appear 2-3 times.
Message persistence layer
DataTwo Supabase PostgreSQL tables: conversations stores the thread metadata, and messages stores each individual message with a foreign key back to the conversation. Row Level Security restricts all reads and writes to verified participants only.
Note: A third join table, conversation_participants, records which user_ids belong to each conversation and tracks last_read_at for read receipt calculations.
File and image attachments
ServiceSupabase Storage bucket holds uploaded files. The client uploads the file, receives a storage path, then inserts the message row with attachment_url set to a signed URL expiring in 1 hour. The signed URL prevents participants from sharing links that work for non-participants.
Note: Signed URLs expire — do not store them in the messages table permanently. Store the storage path and regenerate a signed URL at render time for images still being displayed.
Push notification trigger
ServiceA Supabase Edge Function listens for new message inserts and checks whether the recipient is currently active via the Realtime Presence channel. If the recipient is offline, it calls the OneSignal REST API (or Firebase Cloud Messaging) to send a push notification with the sender name and message preview.
Note: FCM requires VAPID keys for web push. Store the VAPID public key in Supabase Secrets (Cloud tab) and include it in the push() call inside the Edge Function.
The data model
Three tables cover the full messaging schema: conversation threads, participant membership with read tracking, and individual messages. Run this in the Supabase SQL editor.
1create table public.conversations (2 id uuid primary key default gen_random_uuid(),3 type text not null default 'direct' check (type in ('direct', 'group')),4 name text,5 created_at timestamptz not null default now()6);78create table public.conversation_participants (9 conversation_id uuid references public.conversations(id) on delete cascade not null,10 user_id uuid references auth.users(id) on delete cascade not null,11 last_read_at timestamptz,12 joined_at timestamptz not null default now(),13 primary key (conversation_id, user_id)14);1516create table public.messages (17 id uuid primary key default gen_random_uuid(),18 conversation_id uuid references public.conversations(id) on delete cascade not null,19 sender_id uuid references auth.users(id) on delete set null,20 body text,21 attachment_url text,22 deleted_at timestamptz,23 created_at timestamptz not null default now()24);2526alter table public.conversations enable row level security;27alter table public.conversation_participants enable row level security;28alter table public.messages enable row level security;2930-- Participants can read conversations they belong to31create policy "participants can select conversations"32 on public.conversations for select33 using (34 auth.uid() in (35 select user_id from public.conversation_participants36 where conversation_id = conversations.id37 )38 );3940-- Participants can insert into conversation_participants for their own user41create policy "participants can insert"42 on public.conversation_participants for insert43 with check (auth.uid() = user_id);4445-- Participants can select their own participant rows46create policy "participants can select own rows"47 on public.conversation_participants for select48 using (auth.uid() = user_id);4950-- Participants can update their own last_read_at51create policy "participants can update own last_read_at"52 on public.conversation_participants for update53 using (auth.uid() = user_id);5455-- Participants can read messages in their conversations56create policy "participants can select messages"57 on public.messages for select58 using (59 auth.uid() in (60 select user_id from public.conversation_participants61 where conversation_id = messages.conversation_id62 )63 );6465-- Participants can send messages in their conversations66create policy "participants can insert messages"67 on public.messages for insert68 with check (69 auth.uid() = sender_id70 and auth.uid() in (71 select user_id from public.conversation_participants72 where conversation_id = messages.conversation_id73 )74 );7576-- Users can soft-delete their own messages77create policy "sender can soft delete messages"78 on public.messages for update79 using (auth.uid() = sender_id);8081create index messages_conversation_created_idx82 on public.messages (conversation_id, created_at desc);8384create index conversation_participants_user_idx85 on public.conversation_participants (user_id);Heads up: The messages index on (conversation_id, created_at desc) is critical once threads accumulate hundreds of messages. Soft-delete via deleted_at lets you show 'Message deleted' in the thread without removing the row — downstream read-receipt calculations stay accurate.
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: Lovable auto-wires Supabase Realtime and Auth, and scaffolds the conversation UI, message thread, and read receipts in a single prompt. The subscription cleanup and group-chat RLS need explicit specification.
Step by step
- 1Create a new Lovable project and connect Lovable Cloud so Supabase is provisioned automatically with auth enabled
- 2Paste the prompt below in Agent Mode — Lovable will generate the conversations table, participant RLS, message thread, and real-time subscription in one pass
- 3Open the Supabase Cloud tab in Lovable and verify the three tables were created; check the RLS policies in the Supabase dashboard
- 4Add ONESIGNAL_API_KEY and ONESIGNAL_APP_ID to the Secrets panel (Cloud tab) before testing push notifications
- 5Publish to your Lovable URL and test on two browser tabs logged in as different users — camera and push cannot be tested in the editor preview
Build an in-app messaging feature using Supabase. Create three tables: conversations (id, type 'direct'|'group', name, created_at), conversation_participants (conversation_id, user_id, last_read_at, joined_at), and messages (id, conversation_id, sender_id, body, attachment_url, deleted_at, created_at). Enable RLS: participants can only SELECT conversations and messages they are listed in via conversation_participants; participants can INSERT messages where sender_id = auth.uid() and they are a participant. UI: left panel showing conversation list with avatar, last message preview, unread count badge (messages since last_read_at), and relative timestamp. Right panel: virtualised message thread (react-virtuoso) with bubble layout — my messages right-aligned, others left-aligned. Soft-deleted messages show 'Message deleted' instead of body. Message input at the bottom: textarea with emoji-mart v5 picker and image attachment button (upload to Supabase Storage, store path, generate signed URL expiring 1 hour). Subscribe to messages using supabase channel().on('postgres_changes') filtered by conversation_id — always call channel.unsubscribe() in the useEffect cleanup. Add a typing indicator using Supabase Realtime Presence: broadcast my typing state and render a 'typing...' indicator when presence.user_id !== currentUser.id. On message receipt when the recipient is not in the conversation view, trigger a Supabase Edge Function that calls OneSignal REST API with the sender name and message preview. Handle the empty state when the user has no conversations yet with a 'Start your first conversation' prompt.Where this path bites
- AI may generate duplicate Realtime channel subscriptions if the message thread re-renders — verify the useEffect cleanup is present in the generated code
- Group chat with more than 2 participants sometimes has incomplete RLS; audit the conversation_participants INSERT policy before launch
- Signed URL regeneration for attachment images is often skipped — stored paths work in preview but expire after 1 hour in production
- Push notifications require the published URL, not the Lovable preview, to test VAPID flow
Third-party services you'll need
Two external services are needed beyond Supabase: a push notification provider for offline delivery, and optionally a second push provider as a fallback.
| Service | What it does | Free tier | Paid from |
|---|---|---|---|
| Supabase | Realtime message delivery, PostgreSQL persistence, Storage for attachments, Edge Functions for push dispatch | Free: 500 MB DB, 2 GB bandwidth, 200 concurrent Realtime connections | Pro $25/mo: 8 GB DB, 250 GB bandwidth, 500 Realtime connections |
| OneSignal | Push notifications for offline recipients on web and mobile | Free: unlimited push, up to 10K subscribers | Growth $9/mo (approx) for higher subscriber counts and analytics |
| Firebase Cloud Messaging (FCM) | Alternative push provider; free at any volume; requires more setup than OneSignal | Free | Pay-as-you-go for high volume (approx) |
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 handles the message volume and 200 concurrent Realtime connections easily. OneSignal free covers push notifications. No paid tier required.
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.
Duplicate messages appear on every render
Symptom: The Supabase Realtime channel is subscribed inside a React useEffect without a cleanup function. On hot-reload, navigation away and back, or React StrictMode double-invocation, a second active subscription is created alongside the first — each new message is delivered to both handlers, inserting the same bubble twice in the message list.
Fix: Return channel.unsubscribe() from the useEffect cleanup: useEffect(() => { const channel = supabase.channel(...).subscribe(); return () => { channel.unsubscribe(); }; }, [conversationId]). Check the Supabase Realtime inspector in the dashboard to confirm only one active subscription exists per conversation.
RLS blocks new conversation creation
Symptom: AI generates an INSERT policy for the messages table but forgets to add an INSERT policy on conversation_participants. When a user tries to start a new conversation, the INSERT into conversation_participants fails with 'new row violates row-level security policy' — the conversation row is created but the user can never read it back.
Fix: Add the explicit participant INSERT policy: CREATE POLICY "participants can insert" ON conversation_participants FOR INSERT WITH CHECK (auth.uid() = user_id). Also verify there is a policy allowing conversations INSERT before participant rows are written.
Attachment images fail in the Lovable preview
Symptom: Supabase Storage signed URLs are generated inside the Lovable preview iframe. The iframe's sandbox restrictions treat the Supabase origin as cross-origin and block the signed URL request — images render as broken icons. The same code works correctly on the published URL.
Fix: Test all file upload and image display functionality on the published Lovable URL, not in the editor preview panel. This is a preview-only limitation and does not indicate a bug in the generated code.
Typing indicator shows for the sender's own typing
Symptom: The Supabase Realtime Presence channel broadcasts the typing state to all subscribers in the channel — including the sender themselves. Without filtering, users see their own typing indicator appear below the input while they type, which is confusing.
Fix: Filter presence events on the receiver side: only render the typing indicator when the presence payload user_id differs from the current user's id. In code: presenceState.filter(p => p.user_id !== currentUser.id).length > 0.
Push notifications absent on mobile web
Symptom: FCM web push requires a VAPID key pair configured in the Supabase Edge Function and a browser permission prompt to grant notification access. AI-generated Edge Functions frequently omit the VAPID public key from the push() call, causing FCM to silently reject the request with a 400 error.
Fix: Add the VAPID public key to Supabase Secrets via the Cloud tab, then include applicationServerKey: Deno.env.get('VAPID_PUBLIC_KEY') in the FCM push payload. Verify the permission prompt is triggered on first app load and that the subscription object is saved to Supabase for use in the Edge Function.
Best practices
Always unsubscribe Supabase Realtime channels in useEffect cleanup — multiple subscriptions are the most common cause of duplicate messages in production
Store attachment storage paths, not signed URLs, in the messages table — regenerate signed URLs at render time to avoid broken links after 1 hour
Use soft-delete (deleted_at timestamp) rather than hard DELETE so read-receipt calculations and conversation list previews remain accurate
Filter typing presence events to exclude the current user — showing your own typing indicator to yourself is always a bug
Index messages on (conversation_id, created_at desc) from day one — without it, thread loading slows dramatically past 10,000 messages
Rate-limit message inserts per user per conversation (one message per 500 ms) to prevent accidental double-sends from rapid tapping on mobile
Show a graceful empty state when a user has no conversations — a blank screen makes the feature look broken
When You Need Custom Development
Lovable and v0 comfortably ship DM and small group chat. A few scenarios genuinely require a custom build:
- You need end-to-end encryption (Signal Protocol or libsodium) so the server cannot read message content — AI tools have no pattern for client-side key management
- Message volume exceeds 500 Supabase Realtime concurrent connections on the Pro plan and you cannot shard across multiple Supabase projects
- You need compliance features: HIPAA data residency, GDPR message retention policies, legal hold, or audit logs of every message seen
- You need custom WebSocket infrastructure with sub-50 ms delivery latency at more than 100K concurrent users — Supabase Realtime is not designed for that scale
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
Can I add in-app messaging without a backend?
No — messaging requires a database for persistence, a real-time layer for live delivery, and server-side security rules to prevent users from reading each other's conversations. Supabase gives you all three and is the most common backend for AI-generated apps. Third-party SDKs like Stream Chat abstract the backend but charge $500+/mo at scale.
What is the cheapest way to add real-time chat to a Lovable app?
Supabase Realtime on the free tier covers 200 concurrent connections — enough for apps with up to roughly 200 active users at once. Total cost at this scale is $0/mo. When you upgrade to Supabase Pro ($25/mo) you get 500 concurrent connections and enough database and bandwidth for 1,000+ users.
How do I add read receipts to in-app messaging?
Store a last_read_at timestamp in the conversation_participants table. When a user opens a conversation, update their last_read_at to now(). The unread count is the number of messages with created_at greater than last_read_at for that participant. The sender can query this column to show a 'Seen' indicator.
Can users send images and files in the chat?
Yes. Upload the file to a Supabase Storage bucket and store the storage path in the attachment_url column of the messages table. Generate a signed URL expiring in 1 hour at render time — do not store the signed URL itself, because it will break after expiry. Supabase Storage enforces RLS, so only conversation participants can fetch the signed URL.
How do I show a notification when a new message arrives while the user is on a different page?
A Supabase Edge Function listens for INSERT events on the messages table. It checks whether the recipient is currently active via the Realtime Presence channel. If the recipient is offline, the Edge Function calls OneSignal or Firebase Cloud Messaging (FCM) with the sender name and message preview. For web push, FCM requires a VAPID key configured in Supabase Secrets.
Does Supabase Realtime support group chats?
Yes. Subscribe the Realtime channel to postgres_changes on the messages table filtered by conversation_id — all participants who subscribe to the same conversation_id receive the same messages. The group is defined by the conversation_participants table, and RLS enforces that only listed participants can insert or read messages.
How do I prevent users from reading each other's private messages?
Row Level Security on the messages table. The SELECT policy checks that auth.uid() exists in the conversation_participants table for that conversation_id. Without this policy, any authenticated user can read any message row. Always verify in the Supabase dashboard that RLS is enabled on all three tables — it is not enabled by default on new tables.
What is the difference between in-app messaging and push notifications?
In-app messaging is the persistent conversation interface inside your product — users read and send messages while actively using the app. Push notifications are the delivery mechanism for users who are not in the app — a banner on their phone or desktop that brings them back. They work together: Supabase Realtime handles delivery when the user is active; a push provider like OneSignal handles delivery when they are offline.
Need this feature production-ready?
RapidDev builds in-app messaging into real apps — auth, database, payments — at $13K–$25K.
Book a free consultation30-min call. No commitment.