# How to Add In-App Messaging to Your App (Copy-Paste Prompts Included)

- Tool: App Features
- Last updated: July 2026

## TL;DR

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.

## 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** (ui): Shows 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.
- **Message thread view** (ui): A 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.
- **Message input composer** (ui): A 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.
- **Supabase Realtime channel** (backend): Uses 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.
- **Message persistence layer** (data): Two 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.
- **File and image attachments** (service): Supabase 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.
- **Push notification trigger** (service): A 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.

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

```sql
create table public.conversations (
  id uuid primary key default gen_random_uuid(),
  type text not null default 'direct' check (type in ('direct', 'group')),
  name text,
  created_at timestamptz not null default now()
);

create table public.conversation_participants (
  conversation_id uuid references public.conversations(id) on delete cascade not null,
  user_id uuid references auth.users(id) on delete cascade not null,
  last_read_at timestamptz,
  joined_at timestamptz not null default now(),
  primary key (conversation_id, user_id)
);

create table public.messages (
  id uuid primary key default gen_random_uuid(),
  conversation_id uuid references public.conversations(id) on delete cascade not null,
  sender_id uuid references auth.users(id) on delete set null,
  body text,
  attachment_url text,
  deleted_at timestamptz,
  created_at timestamptz not null default now()
);

alter table public.conversations enable row level security;
alter table public.conversation_participants enable row level security;
alter table public.messages enable row level security;

-- Participants can read conversations they belong to
create policy "participants can select conversations"
  on public.conversations for select
  using (
    auth.uid() in (
      select user_id from public.conversation_participants
      where conversation_id = conversations.id
    )
  );

-- Participants can insert into conversation_participants for their own user
create policy "participants can insert"
  on public.conversation_participants for insert
  with check (auth.uid() = user_id);

-- Participants can select their own participant rows
create policy "participants can select own rows"
  on public.conversation_participants for select
  using (auth.uid() = user_id);

-- Participants can update their own last_read_at
create policy "participants can update own last_read_at"
  on public.conversation_participants for update
  using (auth.uid() = user_id);

-- Participants can read messages in their conversations
create policy "participants can select messages"
  on public.messages for select
  using (
    auth.uid() in (
      select user_id from public.conversation_participants
      where conversation_id = messages.conversation_id
    )
  );

-- Participants can send messages in their conversations
create policy "participants can insert messages"
  on public.messages for insert
  with check (
    auth.uid() = sender_id
    and auth.uid() in (
      select user_id from public.conversation_participants
      where conversation_id = messages.conversation_id
    )
  );

-- Users can soft-delete their own messages
create policy "sender can soft delete messages"
  on public.messages for update
  using (auth.uid() = sender_id);

create index messages_conversation_created_idx
  on public.messages (conversation_id, created_at desc);

create index conversation_participants_user_idx
  on public.conversation_participants (user_id);
```

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 paths

### Lovable — fit 5/10, 4-6 hours

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.

1. Create a new Lovable project and connect Lovable Cloud so Supabase is provisioned automatically with auth enabled
2. Paste the prompt below in Agent Mode — Lovable will generate the conversations table, participant RLS, message thread, and real-time subscription in one pass
3. Open the Supabase Cloud tab in Lovable and verify the three tables were created; check the RLS policies in the Supabase dashboard
4. Add ONESIGNAL_API_KEY and ONESIGNAL_APP_ID to the Secrets panel (Cloud tab) before testing push notifications
5. Publish 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

Starter prompt:

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

Limitations:

- 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

### V0 — fit 4/10, 5-8 hours

Best when messaging is embedded in an existing Next.js app. v0 generates a clean component structure with the Supabase subscription in a client component and Server Components for the initial conversation list load.

1. Prompt v0 with the spec below to generate the InboxPanel, MessageThread, and MessageComposer components
2. Open the Vars panel in v0 and add NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY
3. Run the SQL schema from this page in the Supabase SQL editor to create the three tables and RLS policies
4. Deploy to Vercel — Supabase Realtime subscriptions require a live HTTPS URL to confirm WebSocket connections work across networks
5. Add ONESIGNAL_API_KEY to Vercel environment variables and create the Edge Function or API route for push dispatch

Starter prompt:

```
Build an in-app messaging feature for a Next.js 14 App Router app using Supabase. Components: InboxPanel (server component) — fetches conversations for auth.uid() using a Supabase server client, shows avatar, last message preview, unread count; MessageThread (client component) — subscribes to messages via supabase.channel().on('postgres_changes') filtered by conversation_id, renders a virtualised list using react-virtuoso, unsubscribes in useEffect cleanup; MessageComposer (client component) — textarea with emoji picker (emoji-mart v5), image file input that uploads to Supabase Storage and inserts a signed URL expiring 1 hour, sends on Enter key. Typing indicator via Supabase Realtime Presence channel: track({ user_id }) on focus, untrack on blur, only show indicator when presence user_id differs from current user. Wire all three on /messages/[conversationId]. Support soft-delete: deleted messages show 'Message deleted'. Empty state when user has no conversations. Include TypeScript types for Conversation, Message, and Participant. Supabase tables: conversations, conversation_participants, messages — use RLS policies that restrict SELECT and INSERT to conversation participants only.
```

Limitations:

- Supabase is not auto-provisioned — you must create the schema manually in the Supabase dashboard
- The real-time subscription is a client component; the initial load is a server component — the client/server boundary must be explicit with 'use client' or hydration errors appear
- Push notifications for offline users require a separate API route or Edge Function not generated by the initial v0 prompt

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

The mobile-first path. Firebase Firestore real-time listeners work out of the box for direct messages and give offline support for free. Group chat beyond 2 participants requires a custom Dart action.

1. Create a Firestore collection 'conversations' with documents containing participantIds array, lastMessage map, and updatedAt timestamp
2. Add a 'messages' subcollection inside each conversation document with sender, body, attachmentUrl, and createdAt fields
3. In the FlutterFlow UI, add a ListView widget bound to a Firestore collection query filtered where participantIds array-contains currentUserRef
4. Build the message thread page with a second ListView bound to the messages subcollection, ordered by createdAt ascending; enable auto-scroll on new items
5. Add a Firebase Storage action to the image attachment button and write the download URL into the message document
6. Configure Firebase Cloud Messaging in FlutterFlow Settings and add the push notification action to the message send flow

Limitations:

- Group chat beyond 2 participants requires a custom Dart action for participant management — the FlutterFlow UI builder cannot express the query logic
- Web export has known performance issues with large message lists — virtualisation is not built into FlutterFlow's ListView
- Typing indicators require a separate Firestore document to track presence state; no native FlutterFlow action covers this

### Custom — fit 2/10, 2-4 weeks

Only justified for end-to-end encryption, on-premise hosting, compliance archiving, or more than 100K concurrent users. The canonical stack is Socket.io + Node.js + PostgreSQL for the message layer.

1. E2E encryption using Signal Protocol (libsodium) — client-side key generation and message encryption before any network call
2. On-premise WebSocket server with Socket.io + Redis Pub/Sub for fan-out across multiple Node.js processes
3. Message retention and audit log pipeline for HIPAA or GDPR data residency requirements
4. Custom TURN server for media relay if the feature includes file streaming at scale

Limitations:

- 3-4 weeks of development for a feature Lovable ships in 4-6 hours — only justified when encryption or compliance is non-negotiable

## Gotchas

- **Duplicate messages appear on every render** — 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** — 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** — 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** — 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** — 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

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

---

Source: https://www.rapidevelopers.com/app-features/in-app-messaging
© RapidDev — https://www.rapidevelopers.com/app-features/in-app-messaging
