Skip to main content
RapidDev - Software Development Agency
Lovable PromptsSocialIntermediate

Build a Chat Application in Lovable

A real-time peer messaging app with one-on-one and group conversations, message history, typing indicators, optional file attachments, and tight per-participant RLS — built on Supabase Realtime channels (the real feature, not the fictional lovable-realtime import).

Time to MVP

~1 day

Credits

~100–160 credits for full chain

Difficulty

Intermediate

Cloud features

3

TL;DR

The one-paragraph version before you dive in.

Paste the starter prompt into Lovable Build mode and get a real-time peer messaging app: one-on-one and group conversations, message history, typing indicators, and per-participant RLS using Supabase Realtime postgres_changes. Full build takes about 1 day and ~100–160 credits. lovable-realtime does not exist — this kit uses the real Supabase Realtime API.

Setup checklist

Complete these steps in Lovable before pasting the starter prompt — takes about 5 minutes.

Cloud tab settings

Database

Stores conversations, participants, and messages. Realtime must be explicitly enabled on the messages table via ALTER PUBLICATION — this is the most commonly missed step.

  1. 1Click the + button next to Preview in the top toolbar to open the Cloud tab
  2. 2Click Database — you will see an empty Table Editor
  3. 3Leave it empty for now — the starter prompt creates all tables including the critical ALTER PUBLICATION line

Auth

Auth user IDs drive all participant RLS. Every conversation, participant row, and message is scoped to auth.uid().

  1. 1Cloud tab → Users & Auth
  2. 2Under Sign-in methods, confirm Email is enabled (it is by default)
  3. 3Enable Google OAuth if you want one-click sign-in (optional for v1)
  4. 4Set 'Allow new users to sign up' to ON — users self-register

Storage

Optional bucket for file and image attachments in chat. Only needed if you run follow-up #5 (attachments).

  1. 1Cloud tab → Storage
  2. 2Click Create bucket, name it 'chat-attachments', leave Public OFF
  3. 3The attachment follow-up prompt will add the per-conversation RLS policy on this bucket

Secrets (Cloud tab → Secrets)

RESEND_API_KEY

Purpose: Sends email notifications to participants who are offline when a new message arrives (follow-up #4)

Where to get it: https://resend.com/api-keys — create an API key with full sending permissions

Preflight checklist

  • You are in a fresh Lovable project (Vite + React + TypeScript + Tailwind + shadcn/ui already scaffolded — that is the default)
  • You are on the Pro $25/mo plan — the full chain runs ~100–160 credits and Free caps at ~30/month
  • You understand the Supabase Realtime limit: 200 concurrent connections on the Free tier, 500 on Pro. If you expect more than ~100 daily active chatters, plan for Supabase Pro or Pusher from day one
  • lovable-realtime does NOT exist as an import. This kit uses supabase.channel().on('postgres_changes', ...).subscribe() — the real Supabase Realtime API

The starter prompt

Copy this. Paste it into Lovable Build mode (the default chat at the bottom-left of the editor). Hit send.

lovable-agent-mode.txt
~50–70 credits
Build a real-time chat application using React + Vite + TypeScript + Tailwind CSS + shadcn/ui on the frontend and Supabase (Lovable Cloud) on the backend. Use the standard @supabase/supabase-js client and Supabase Realtime postgres_changes API. Do NOT use any import called lovable-realtime — it does not exist.

Create one SQL migration file at supabase/migrations/0001_chat_schema.sql with the following:

1. SECURITY DEFINER function is_participant(p_conversation_id uuid)
   LANGUAGE plpgsql
   AS $$ BEGIN RETURN EXISTS (SELECT 1 FROM participants WHERE conversation_id = p_conversation_id AND user_id = auth.uid()); END; $$;
   This function runs as the function owner (bypassing RLS on participants) and is used by all three table policies below.

2. Table: conversations
   - id uuid PRIMARY KEY DEFAULT gen_random_uuid()
   - title text (null for 1-on-1, set for group)
   - is_group bool DEFAULT false
   - created_by uuid REFERENCES auth.users(id)
   - last_message_at timestamptz DEFAULT now()
   - created_at timestamptz DEFAULT now()
   - RLS: Enable RLS. SELECT policy: USING (is_participant(id)). INSERT policy: WITH CHECK (created_by = auth.uid()).

3. Table: participants
   - conversation_id uuid REFERENCES conversations(id) ON DELETE CASCADE
   - user_id uuid REFERENCES auth.users(id)
   - role text DEFAULT 'member' CHECK (role IN ('admin','member'))
   - joined_at timestamptz DEFAULT now()
   - last_read_at timestamptz
   - PRIMARY KEY (conversation_id, user_id)
   - RLS: Enable RLS. SELECT policy: USING (is_participant(conversation_id)). INSERT policy: WITH CHECK (user_id = auth.uid() OR EXISTS (SELECT 1 FROM participants WHERE conversation_id = participants.conversation_id AND user_id = auth.uid() AND role = 'admin')).

4. Table: messages
   - id uuid PRIMARY KEY DEFAULT gen_random_uuid()
   - conversation_id uuid REFERENCES conversations(id) ON DELETE CASCADE
   - sender_id uuid REFERENCES auth.users(id)
   - body text
   - attachment_path text
   - is_deleted bool DEFAULT false
   - created_at timestamptz DEFAULT now()
   - edited_at timestamptz
   - RLS: Enable RLS. SELECT policy: USING (is_participant(conversation_id) AND is_deleted = false). INSERT policy: WITH CHECK (sender_id = auth.uid() AND is_participant(conversation_id)). UPDATE policy: USING (sender_id = auth.uid()) WITH CHECK (sender_id = auth.uid()).

5. CRITICAL: Enable Realtime on the messages table:
   ALTER PUBLICATION supabase_realtime ADD TABLE messages;
   (Without this line, real-time updates will silently fail in production.)

6. Also enable Realtime on conversations for last_message_at updates:
   ALTER PUBLICATION supabase_realtime ADD TABLE conversations;

Build the following layout and pages:

Layout: AppLayout — two-column shell on desktop (left sidebar: 320px conversation list; right: active conversation thread). On mobile, show conversation list by default; clicking a conversation navigates to the thread and shows a back button.

Pages:
- /chat: conversation list (default route after sign-in)
- /chat/:id: active conversation thread view
- /chat/new: new conversation flow — search for another user by email, optionally add more users for a group chat, set group title if group
- /settings: display name update, sign out

Components:
1. ConversationList: renders all conversations ordered by last_message_at DESC. Each row: ConversationListItem with avatar (initials fallback), name (other participant's name for 1-on-1, title for group), last message preview (max 60 chars), relative timestamp, UnreadBadge count.

2. ConversationListItem: clickable row (60px height) that navigates to /chat/:id. Shows UnreadBadge when unread_count > 0.

3. MessageThread: full-height scrollable list of MessageBubble components for the active conversation. Subscribes to Realtime on mount via useRealtimeMessages(conversationId) hook. Scrolls to bottom when new messages arrive. Shows a TypingIndicator at the bottom when another participant is typing.

4. MessageBubble: own messages right-aligned (blue-500 bg), others left-aligned (neutral-100 bg). Show sender avatar and name for group chats. Show timestamp on hover. Show edited indicator if edited_at is set.

5. MessageComposer: textarea (auto-grows, max 5 lines) + send button + emoji button. On Enter (without Shift), sends the message. On Shift+Enter, inserts a newline. INSERT into messages sets conversation.last_message_at to now() via a Postgres trigger (create this trigger in the migration).

6. TypingIndicator: animated three-dot pulse. Shown for 3 seconds after receiving a typing broadcast, then cleared.

7. UnreadBadge: red pill with count. Shown when message.created_at > participant.last_read_at.

Hook: useRealtimeMessages(conversationId)
- On mount: subscribe to supabase.channel('conv:' + conversationId).on('postgres_changes', {event: 'INSERT', schema: 'public', table: 'messages', filter: 'conversation_id=eq.' + conversationId}, handler).subscribe()
- On new message: append to local state
- On unmount: call channel.unsubscribe()
- Include the filter on conversation_id — do NOT subscribe to all messages globally

/chat/new flow: text input to search users by email (SELECT id, email FROM auth.users WHERE email ILIKE '%' || query || '%' LIMIT 10 — use an Edge Function or RPC for this since auth.users is not directly queryable from the client). On select, create a conversation row + two participant rows in a single transaction. Redirect to /chat/:newConversationId.

Styling: Light + dark mode (ThemeProvider). Own messages blue-500. Others neutral-100 / neutral-800 dark. Message bubbles 16px border-radius. Conversation list compact density (60px rows). Send button uses the PaperPlaneRight icon from lucide-react.

Deliverable: A working chat app where two signed-in users can start a 1-on-1 conversation, send messages, and see each other's messages appear in real time without refreshing. The /chat/new flow allows picking a recipient by email lookup.

What this prompt generates

  • Creates a SQL migration with 3 tables (conversations, participants, messages), the is_participant() SECURITY DEFINER function, RLS policies on all three tables, and the critical ALTER PUBLICATION line for Realtime
  • Generates an AppLayout with two-column desktop and mobile-responsive conversation list
  • Builds /chat/:id with a Realtime-subscribed MessageThread and MessageComposer
  • Creates a useRealtimeMessages hook with correct channel filter so subscribers only receive messages for their active conversation
  • Implements /chat/new for starting conversations by email lookup
  • Produces ConversationListItem, MessageBubble, MessageComposer, and TypingIndicator components

Paste into: Lovable Build mode (the default chat at the bottom-left of the editor)

Expected output

What Lovable will generate after the starter prompt runs successfully.

Files
supabase/migrations/0001_chat_schema.sql

3 tables + is_participant() function + RLS + ALTER PUBLICATION for Realtime + last_message_at trigger

src/layouts/AppLayout.tsx

Two-column chat shell with responsive mobile fallback

src/components/AuthGuard.tsx

Redirect unauthenticated users to /login

src/components/ConversationList.tsx

Sidebar list ordered by last_message_at

src/components/MessageThread.tsx

Scrollable message list with Realtime subscription

src/components/MessageBubble.tsx

Own vs other message alignment, hover timestamps

src/components/MessageComposer.tsx

Auto-growing textarea with Enter-to-send

src/components/TypingIndicator.tsx

Animated three-dot pulse for other participants typing

src/hooks/useRealtimeMessages.ts

Supabase channel subscription with conversation_id filter

src/pages/Chat.tsx

Conversation list page

src/pages/ChatThread.tsx

Active conversation thread at /chat/:id

src/pages/NewChat.tsx

New conversation flow with email-based user search

Routes
/chat

Conversation list ordered by most recent message

/chat/:id

Active thread view with Realtime subscription and MessageComposer

/chat/new

Start a new 1-on-1 or group conversation by email search

/settings

Display name update and sign-out

DB Tables
conversations
ColumnType
iduuid primary key
titletext
is_groupbool default false
created_byuuid references auth.users(id)
last_message_attimestamptz default now()
created_attimestamptz default now()

RLS: SELECT via is_participant(id). Only participants can see the conversations they belong to.

participants
ColumnType
conversation_iduuid references conversations(id) on delete cascade
user_iduuid references auth.users(id)
roletext default 'member'
joined_attimestamptz default now()
last_read_attimestamptz

RLS: SELECT via is_participant(conversation_id). The is_participant() SECURITY DEFINER function reads this table without triggering recursion.

messages
ColumnType
iduuid primary key
conversation_iduuid references conversations(id) on delete cascade
sender_iduuid references auth.users(id)
bodytext
attachment_pathtext
is_deletedbool default false
created_attimestamptz default now()
edited_attimestamptz

RLS: SELECT via is_participant(conversation_id) AND is_deleted = false. INSERT requires sender_id = auth.uid(). This table must have ALTER PUBLICATION supabase_realtime ADD TABLE messages; or Realtime updates will not work.

Components
ConversationList

Sidebar with all user conversations, ordered by last_message_at

MessageThread

Realtime-subscribed scrollable message list

MessageBubble

Styled chat bubble with own/other alignment

MessageComposer

Auto-growing textarea with Enter-to-send

TypingIndicator

Animated dots shown during other participant typing

UnreadBadge

Red count pill on conversation list items

Follow-up prompts

Paste these into Agent Mode one by one, in order, after the starter prompt finishes.

1

Audit participant RLS — verify cross-conversation isolation

Verified cross-conversation isolation — each user sees only their own conversations and messages

~20–30 credits
prompt
Audit and test the participant RLS to confirm that users cannot see conversations or messages they are not a participant in.

1. Confirm the is_participant() function exists with SECURITY DEFINER:
   SELECT proname, prosecdef FROM pg_proc WHERE proname = 'is_participant';
   If prosecdef is false, the function is NOT running as the function owner and will not bypass RLS on participants. Recreate it with SECURITY DEFINER.

2. Confirm every SELECT policy on conversations, participants, and messages uses is_participant(), NOT a self-referencing subquery.

3. Test with two separate browser sessions (User A and User B):
   - User A creates a conversation with User B
   - Open a third incognito window as User C (a different account)
   - Sign in as User C and navigate to /chat — the conversation between A and B must NOT appear
   - Try fetching /rest/v1/messages with User C's JWT — zero rows must be returned

4. Fix any policy that uses auth.uid() = sender_id globally (too permissive — allows cross-conversation reads). Replace with: USING (is_participant(conversation_id) AND is_deleted = false).

Deliverable: Two separate users cannot see each other's conversations or messages. A third user with a valid JWT cannot access any conversation they were not added to.

When to use: Immediately after the starter prompt, before sharing with any real users

2

Add typing indicators via Realtime broadcast

Ephemeral typing indicators via Realtime broadcast — no DB writes, no RLS needed

~20–30 credits
prompt
Add typing indicators using Supabase Realtime broadcast channels — no database writes needed for this.

In the MessageComposer component:
1. On the textarea onChange event (debounced with a 500ms timeout), broadcast a typing event to a channel named 'typing:' + conversationId:
   await supabase.channel('typing:' + conversationId).send({type: 'broadcast', event: 'typing', payload: {user_id: currentUser.id, display_name: currentUser.display_name}})
2. Do NOT send on every keystroke — debounce so it fires at most once per 500ms of continuous typing.

In the MessageThread component:
1. Subscribe to the same channel and listen for 'typing' events:
   supabase.channel('typing:' + conversationId).on('broadcast', {event: 'typing'}, ({payload}) => { if (payload.user_id !== currentUser.id) { setTypingUser(payload.display_name); clearTimeout(typingTimeout); typingTimeout = setTimeout(() => setTypingUser(null), 3000); } }).subscribe()
2. Show the TypingIndicator component below the message list when typingUser is not null, with text '[Name] is typing...' and the animated three-dot pulse.
3. Clear the indicator after 3 seconds of no new typing broadcasts.

Important: Realtime broadcast channels are ephemeral — they do not require any DB table or RLS setup. They are separate from the postgres_changes subscription for messages.

Deliverable: When User A types in a conversation, User B sees '[Name] is typing...' with animated dots in the MessageThread. The indicator disappears 3 seconds after User A stops typing.

When to use: Once basic chat is working and you want to add the first interactivity polish

3

Add read receipts and unread count badges

Per-conversation unread message counts with live updates and read-receipt tracking

~35–45 credits
prompt
Add read receipts by tracking participants.last_read_at and computing unread counts.

1. Update participants.last_read_at whenever a user opens (or scrolls to the bottom of) a conversation:
   await supabase.from('participants').update({last_read_at: new Date().toISOString()}).eq('conversation_id', conversationId).eq('user_id', currentUser.id)
   Call this in the MessageThread component's useEffect when the conversation is mounted and when the user scrolls to the bottom.

2. In ConversationList, compute the unread count for each conversation:
   For each conversation, query: SELECT COUNT(*) FROM messages WHERE conversation_id = id AND created_at > (SELECT last_read_at FROM participants WHERE conversation_id = id AND user_id = auth.uid()) AND sender_id != auth.uid()
   Or create a Postgres RPC: get_unread_counts(p_user_id uuid) RETURNS TABLE(conversation_id uuid, unread_count int) for efficiency (one query instead of N).

3. Render UnreadBadge on ConversationListItem when unread_count > 0. Make the count bold the conversation title in the list.

4. Subscribe to Realtime on conversations table (already in the publication) to refresh the conversation list when last_message_at updates — this keeps the sort order live without polling.

Deliverable: Each conversation in the sidebar shows an unread message count. The badge clears when the user opens and reads the conversation.

When to use: Once the core chat flow is working and you want the inbox to feel like a real chat app

4

Add offline email notifications via Resend

Email notifications for offline participants via Resend, triggered by a Postgres AFTER INSERT trigger

~60–80 credits
prompt
Send email notifications to participants who have been inactive for more than 5 minutes when a new message arrives.

1. Add RESEND_API_KEY to Cloud tab → Secrets.

2. Create a Postgres trigger on messages AFTER INSERT that calls an Edge Function via pg_net:
   CREATE OR REPLACE FUNCTION notify_offline_participants() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN PERFORM net.http_post(url := 'https://[your-project-ref].supabase.co/functions/v1/notify-offline-participants', headers := jsonb_build_object('Authorization', 'Bearer ' || current_setting('app.service_role_key'), 'Content-Type', 'application/json'), body := jsonb_build_object('message_id', NEW.id, 'conversation_id', NEW.conversation_id, 'sender_id', NEW.sender_id)); RETURN NEW; END; $$;
   CREATE TRIGGER after_message_insert AFTER INSERT ON messages FOR EACH ROW EXECUTE FUNCTION notify_offline_participants();

3. Create Edge Function supabase/functions/notify-offline-participants/index.ts:
   - Read message_id, conversation_id, sender_id from request body
   - Query participants WHERE conversation_id = p_conversation_id AND user_id != p_sender_id AND (last_read_at IS NULL OR last_read_at < now() - interval '5 minutes')
   - For each inactive participant, fetch their email from profiles
   - Send via Resend API (POST https://api.resend.com/emails): Subject 'New message from [sender name]', body with first 200 chars of message, CTA link to /chat/:conversationId
   - Return early without throwing if RESEND_API_KEY is not set

4. Add an unsubscribe mechanism: create a GET /unsubscribe?token=... route that sets a participant's email_notifications=false column.

Deliverable: Users who have not been active in the conversation for 5+ minutes receive an email notification when a new message arrives.

When to use: When users report missing messages because they did not have the app open

5

Add image and file attachments

Private file and image attachments scoped to conversation participants via Storage RLS

~50–70 credits
prompt
Add file and image attachment support in conversations.

1. Create the Storage policy on the chat-attachments bucket (you created it in setup):
   CREATE POLICY chat_attachments_access ON storage.objects FOR ALL TO authenticated USING (
     bucket_id = 'chat-attachments'
     AND (storage.foldername(name))[1] IN (
       SELECT conversation_id::text FROM participants WHERE user_id = auth.uid()
     )
   ) WITH CHECK (
     bucket_id = 'chat-attachments'
     AND (storage.foldername(name))[1] IN (
       SELECT conversation_id::text FROM participants WHERE user_id = auth.uid()
     )
   );
   Files must be uploaded with path pattern: {conversationId}/{userId}-{timestamp}-{filename}

2. Update MessageComposer: add a paperclip icon button that opens a hidden file input (accept='image/*,application/pdf,.doc,.docx'). On file select, validate size < 10MB. Upload to supabase.storage.from('chat-attachments').upload(path, file). On success, include attachment_path in the messages INSERT.

3. Update MessageBubble: if message.attachment_path is set:
   - For image files (ends in .jpg, .jpeg, .png, .gif, .webp): render an img tag using supabase.storage.from('chat-attachments').getPublicUrl(attachment_path).data.publicUrl — but since the bucket is private, use createSignedUrl(path, 3600) instead and cache the signed URL in component state
   - For other file types: render a download link with file name and size

4. Show an upload progress indicator in MessageComposer while the file is uploading (0–100%).

Deliverable: Users can attach images and files up to 10MB in conversations. Images render inline. Other files show as download links. Only conversation participants can access the attachments via the Storage policy.

When to use: After core messaging and read receipts are working, when text-only feels limiting

6

Add an AI assistant bot participant

AI assistant bot participant that responds to @mentions using Gemini 3 Flash or Claude Haiku 4.5

~70–90 credits
prompt
Add an optional AI assistant bot that participants can @mention in any conversation.

1. Add is_bot bool DEFAULT false column to participants table.

2. Create a bot user row in auth.users (or use a service account user ID) and add it as a participant to every new conversation via the conversation creation flow. Set its is_bot = true.

3. Create Edge Function supabase/functions/ai-bot-reply/index.ts:
   - Triggered via Postgres trigger on messages AFTER INSERT when body ILIKE '%@assistant%'
   - Fetch last 10 messages in the conversation as context
   - Call Lovable AI panel (Gemini 3 Flash) via the built-in AI connector, OR call Anthropic API (Claude Haiku 4.5 for speed) with ANTHROPIC_API_KEY from Secrets
   - Prompt: 'You are a helpful assistant in a group chat. Here is the conversation history: [last 10 messages]. The latest message is: [body]. Reply concisely in 1-3 sentences.'
   - Insert the bot's reply as a new message row with sender_id = bot_user_id

4. Update MessageBubble: if the sender is a bot (look up participants.is_bot), render a subtle robot icon and 'AI Assistant' name instead of the user's display name.

5. Add ANTHROPIC_API_KEY to Cloud tab → Secrets if using Claude Haiku 4.5 directly.

Deliverable: Typing '@assistant' in any message triggers an AI reply from the bot participant within 2–5 seconds. The reply appears in the thread like any other message.

When to use: After all core chat features work and you want to add an AI feature to differentiate from basic messaging

Common errors

Real error strings you'll see. Find yours, paste the fix prompt.

Messages from conversation A appear in conversation B (cross-conversation leak via Realtime)

The Realtime subscription was set up without a filter on conversation_id, so every INSERT to the messages table broadcasts to every subscriber regardless of which conversation they are viewing.

Fix — paste into Lovable Agent Mode
In the useRealtimeMessages hook, update the channel subscription to include the filter parameter: supabase.channel('conv:' + conversationId).on('postgres_changes', {event: 'INSERT', schema: 'public', table: 'messages', filter: 'conversation_id=eq.' + conversationId}, handler).subscribe(). Each conversation view must use its own channel with the conversation_id filter. Also confirm that RLS is enabled on messages and the SELECT policy uses is_participant(conversation_id) — even if the filter fails, RLS should block the row.
permission denied for table messages (on Realtime subscribe)

Supabase Realtime postgres_changes subscriptions still respect RLS. If the is_participant() function is missing or the policy was not applied, subscribing to the channel returns a permission error.

Fix — paste into Lovable Agent Mode
Confirm is_participant() exists with SECURITY DEFINER: SELECT proname, prosecdef FROM pg_proc WHERE proname = 'is_participant'; If prosecdef is false, recreate: CREATE OR REPLACE FUNCTION is_participant(p_conversation_id uuid) RETURNS boolean LANGUAGE plpgsql SECURITY DEFINER AS $$ BEGIN RETURN EXISTS (SELECT 1 FROM participants WHERE conversation_id = p_conversation_id AND user_id = auth.uid()); END; $$; Then confirm the messages SELECT policy: CREATE POLICY participants_only ON messages FOR SELECT USING (is_participant(conversation_id) AND NOT is_deleted);
Messages don't appear in real-time even though INSERT succeeds

The Realtime publication was not enabled on the messages table. Lovable's default migration often omits the ALTER PUBLICATION line, which means the table is not included in the Supabase Realtime stream.

Manual fix

Open Cloud tab → Database → SQL Editor and run: ALTER PUBLICATION supabase_realtime ADD TABLE messages; Then refresh the page and re-subscribe. Add this line to your original migration if you regenerate the schema — it is easy to forget and the symptom (no real-time updates) is silent and confusing.

200 concurrent connection limit reached — new users cannot connect to Realtime

Supabase Free tier caps Realtime at 200 concurrent connections. A chat-centric app with many active users will hit this wall at ~100–500 daily active chatters depending on session length.

Fix — paste into Lovable Agent Mode
Short-term: upgrade Supabase to Pro $25/mo (500 connection cap). Long-term, refactor to Pusher Channels or Ably. To swap: install pusher-js, create an Edge Function that publishes to Pusher on message INSERT, and replace the useRealtimeMessages hook to subscribe via Pusher instead of supabase.channel. The Pusher Channels API is: const channel = pusher.subscribe('conversation-' + id); channel.bind('new-message', handler);
User B can see User A's conversation list (conversations User B is not part of)

The conversations SELECT policy is too permissive — for example USING (true) or USING (created_by = auth.uid()) which still leaks conversations where the current user is a recipient, not the creator.

Fix — paste into Lovable Agent Mode
Drop the existing SELECT policy on conversations. Create: CREATE POLICY participants_only_select ON conversations FOR SELECT USING (is_participant(id)); Test in two separate incognito windows — User A's conversation list must not include conversations User B had with User C. User B's list must only show conversations User B is in.
Message body still visible after soft-delete (is_deleted = true)

The soft-delete only flipped the boolean but did not null the body column, and the RLS SELECT policy may not filter on is_deleted — allowing a direct API query to still return the row with its body.

Fix — paste into Lovable Agent Mode
Add two things: (1) Update the messages SELECT RLS policy to add AND is_deleted = false to the USING clause. (2) Create a BEFORE UPDATE trigger that nulls the body when is_deleted is set to true: CREATE OR REPLACE FUNCTION null_deleted_message_body() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.is_deleted = true AND OLD.is_deleted = false THEN NEW.body := NULL; END IF; RETURN NEW; END; $$; CREATE TRIGGER before_message_soft_delete BEFORE UPDATE ON messages FOR EACH ROW EXECUTE FUNCTION null_deleted_message_body();

Cost reality

What this build actually costs — no surprises on your card.

Recommended Lovable plan

Pro $25/mo recommended. The full chain runs ~100–160 credits, which fits within a Pro plan if you avoid looping on Realtime subscription debugging. Free plan caps at ~30 credits/month — not enough to complete even the starter prompt.

Monthly run cost breakdown

~100–160 credits (starter ~50–70, six follow-ups ~310 if all done; most ship a working chat with follow-ups #1, #2, and #3 for ~120 total) total
ItemCost
Lovable Pro

Only while iterating — drop to Free after launch since the app runs on Cloud

$25/mo
Supabase / Lovable Cloud

Free tier: 500MB DB holds ~5M short messages; 200 concurrent Realtime connections

$0/mo at MVP scale
Resend

Offline notification emails; $20/mo for 50K

$0/mo up to 3K emails
Custom domain

Optional

~$10–15/yr

Scaling notes: 200 concurrent Realtime connections on the Free tier is the hard ceiling for chat apps. You will hit this at ~100–500 daily active chatters depending on session length. Supabase Pro ($25/mo, 500 connections) buys more runway. Past 500 concurrent, you need Pusher ($49–499/mo) or Ably — plan this migration before launch if your app is chat-first.

Production checklist

Steps to take before you share the URL with real users.

Realtime Publication

  • Verify ALTER PUBLICATION was applied

    Cloud tab → Database → SQL Editor. Run: SELECT tablename FROM pg_publication_tables WHERE pubname = 'supabase_realtime'; Confirm 'messages' appears in the results. If it does not, run: ALTER PUBLICATION supabase_realtime ADD TABLE messages; immediately — this is the #1 reason real-time chat silently fails in production.

  • Test Realtime with two live accounts

    Open two browser tabs, each signed in as a different user. Start a conversation between them. Send a message in Tab 1. Verify it appears in Tab 2 within 1–2 seconds without refreshing.

Domain & SSL

  • Connect a custom domain

    Lovable Dashboard → your project → Settings → Custom Domain. Add a CNAME record at your DNS provider pointing to your Lovable project URL. SSL is automatic.

  • Update OAuth redirect URLs if using Google login

    Cloud tab → Users & Auth → URL Configuration. Add your custom domain to Redirect URLs. Also update in the Google Cloud Console OAuth client's Authorized redirect URIs.

Moderation & Rate Limiting

  • Add message rate limiting

    Create a Postgres function that counts messages per user in the last 10 seconds: CREATE OR REPLACE FUNCTION check_message_rate_limit() RETURNS trigger LANGUAGE plpgsql AS $$ DECLARE msg_count int; BEGIN SELECT COUNT(*) INTO msg_count FROM messages WHERE sender_id = NEW.sender_id AND created_at > now() - interval '10 seconds'; IF msg_count > 10 THEN RAISE EXCEPTION 'Rate limit exceeded'; END IF; RETURN NEW; END; $$; Add this as a BEFORE INSERT trigger on messages.

  • Set a Realtime connection budget alert

    Supabase Dashboard → Settings → Billing Alerts. Set an alert when Realtime connections approach 150 (on Free) or 400 (on Pro) so you have time to plan the Pusher migration before hitting the cap.

Backups & Monitoring

  • Confirm daily database backups

    Cloud tab → Database → Backups. Lovable Cloud enables daily backups by default. Confirm at least one backup exists before any soft launch.

Frequently asked questions

Is lovable-realtime a real import I can use?

No. lovable-realtime does not exist as an npm package or Lovable API. It is a hallucination that appears in several competitor prompt guides — if you try to import it, you get a module-not-found error immediately. The real feature is Supabase Realtime, which is part of the @supabase/supabase-js client you already have. The correct pattern is: supabase.channel('your-channel-name').on('postgres_changes', {event: 'INSERT', schema: 'public', table: 'messages', filter: 'conversation_id=eq.' + id}, handler).subscribe(). That is what this kit uses.

How many concurrent users can this handle on the Free tier?

Supabase Free tier caps Realtime at 200 concurrent connections. This is not 200 total users — it is 200 users with the app open and connected simultaneously. In practice, if 10% of your daily active users are online at the same time, you can support ~2,000 DAU before hitting the cap. Supabase Pro ($25/mo) raises the cap to 500 concurrent connections. Past 500, you need to migrate to Pusher Channels (starting at $49/mo for 100 connections) or Ably. Plan this migration before launch if chat is the core feature.

Can I do end-to-end encryption (E2EE) in Lovable?

Not with this starter prompt — messages are stored in plaintext in your Postgres database and are visible to anyone with the service-role key (including Lovable Cloud's infrastructure). True E2EE requires encrypting messages client-side with the recipient's public key before sending, and decrypting client-side after receiving — the database sees only ciphertext. This is technically possible with the Web Crypto API in the browser, but it is complex to implement correctly and is out of scope for a Lovable Build mode session. If E2EE is a hard requirement (HIPAA, legal communications), you should use a purpose-built E2EE messaging service (Matrix/Element, Signal Protocol library) rather than building from scratch in Lovable.

How do I send push notifications to mobile users?

Supabase Realtime only works when the browser tab is open. For background notifications when the app is not open, you have two options. First, the Resend email notification flow in follow-up #4 handles users who have been inactive for 5+ minutes. Second, for true mobile push notifications, you need Web Push API (for progressive web apps) or integrate with Expo Push Notifications if you wrap the app in React Native. The Web Push setup requires a service worker and VAPID key — prompt Lovable with 'Add Web Push notifications to the chat app using the Web Push API and a service worker, with VAPID keys stored in Cloud Secrets.'

What's the cheapest way to scale past 500 concurrent users?

The most practical path is: (1) stay on Supabase Realtime up to ~500 concurrent connections with Supabase Pro ($25/mo); (2) when you approach 400 connections, add Pusher Channels (Starter plan $49/mo for 500K messages/day, unlimited connections). The migration in your codebase is swapping useRealtimeMessages from the Supabase channel pattern to pusher-js, and adding an Edge Function that publishes to Pusher on message INSERT. Ably is a similar alternative starting at $25/mo.

How do I prevent spam or abuse without manual moderation?

Add a message rate limiter as a Postgres BEFORE INSERT trigger: count messages per user in the last 10 seconds and RAISE EXCEPTION if count > 10. This is described in the production checklist. For content moderation, add an OpenAI Moderation API call in a BEFORE INSERT trigger — if the message body is flagged, set is_deleted = true immediately and send a moderation notification to the conversation admin. Without at least rate limiting, a public or semi-public chat will become a spam vector within hours of launch.

Can I embed this chat inside another app I am building in Lovable?

Yes — the chat is built as standard React components and Supabase tables, so you can add it to any other Lovable project. The steps are: (1) copy the SQL migration to the other project and run it; (2) copy the component files; (3) add a /chat route to your other app's router. The only integration point is auth — both apps must use the same Supabase project (same auth.uid() values) for the participant lookups to work. If you have two separate Lovable projects with separate databases, you cannot share chat participants across them without a manual migration.

Can RapidDev build this chat application for me end-to-end?

If your build outgrows this prompt kit and you need custom architecture, RapidDev builds production-grade Lovable apps at $13K-$25K — book a free 30-minute consultation at rapidevelopers.com.

Need a production-grade version?

RapidDev builds production-grade Lovable apps at $13K–$25K. Book a free 30-minute consultation.

Book a free consultation

30-min call. No commitment.

Want this built for you?

We ship production apps at a fixed price — $13K–$25K, 6–10 weeks, source code yours. You've seen what it takes; we do it every week.

Get a fixed-price quote

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.