Skip to main content
RapidDev - Software Development Agency

How to Build a Chat Application with Lovable

Build a real-time chat application in Lovable with conversations, messages, typing indicators via Presence, and file attachments via Supabase Storage. Supabase Realtime delivers messages instantly with optimistic UI so your own messages appear before the database confirms. Unread counts update in the sidebar without polling.

What you'll build

  • Conversations table supporting both one-on-one DMs and group chats via a conversation_participants junction table
  • Messages table with optimistic UI — your messages appear instantly before the Supabase INSERT confirms
  • Supabase Realtime postgres_changes subscription for new message delivery in the active conversation
  • Presence-based typing indicator showing '{{name}} is typing...' that disappears after 3 seconds
  • Unread message count badges on each conversation in the sidebar, updated via Realtime
  • File attachment upload to Supabase Storage with a thumbnail preview in the message bubble
  • Infinite scroll message history loading older messages when the user scrolls to the top
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Advanced16 min read3–4 hoursLovable Pro or higherApril 2026RapidDev Engineering Team
TL;DR

Build a real-time chat application in Lovable with conversations, messages, typing indicators via Presence, and file attachments via Supabase Storage. Supabase Realtime delivers messages instantly with optimistic UI so your own messages appear before the database confirms. Unread counts update in the sidebar without polling.

What you're building

A chat application's complexity lives in three areas: schema design for conversations vs messages, Realtime subscription management, and the unread count system. Getting these right from the start prevents painful refactors later.

The schema uses a conversation_participants junction table instead of storing participant IDs directly on conversations. This supports any number of participants (group chats) and makes it easy to query all conversations for a user with a single JOIN. Unread counts live as a last_read_at timestamp per participant rather than a counter — this avoids race conditions when multiple tabs are open and is accurate even if you miss Realtime events.

Optimistic UI works by assigning a client-side temporary ID to each message before inserting. You add the message to local state immediately with status:'sending', then await the Supabase INSERT. When it confirms, replace the temporary ID with the real UUID from the database response. If the INSERT fails, mark the message status:'failed' and show a retry button.

The Presence subscription tracks typing state. When the input changes, you call channel.track({ typing: true, user_id }). A client-side timeout clears the typing state after 3 seconds of no keystrokes. The Presence 'sync' event fires whenever any participant's state changes, updating the typing indicator in real time.

Final result

A production-ready real-time chat app with DMs, group chats, typing indicators, file attachments, and unread count tracking.

Tech stack

LovableFrontend + Edge Functions
SupabaseDatabase + Realtime + Auth + Storage + Presence
shadcn/uiScrollArea, Avatar, Input, Skeleton
date-fnsRelative timestamps

Prerequisites

  • Lovable Pro account with Supabase Realtime access
  • Supabase project with URL and anon key saved to Cloud tab → Secrets
  • Supabase Storage bucket configured (Lovable creates this in the Cloud tab)
  • Supabase Auth enabled with at least two test user accounts
  • Basic React hooks knowledge — this build uses useEffect, useState, useRef, and useCallback

Build steps

1

Create the chat schema in Supabase

Set up the tables, indexes, and RLS policies for the chat system. The schema design choices here directly affect Realtime filter performance and unread count accuracy.

prompt.txt
1Create a real-time chat application schema. Tables:
2
31. conversations: id, title (text nullable null means 1:1 DM, non-null means group), created_by (references auth.users), created_at, last_message_at (timestamptz)
4
52. conversation_participants: id, conversation_id (references conversations), user_id (references auth.users), last_read_at (timestamptz default now()), joined_at, UNIQUE(conversation_id, user_id)
6
73. messages: id, conversation_id (references conversations), user_id (references auth.users), body (text), file_url (text nullable), file_name (text nullable), file_type (text nullable), created_at, updated_at
8
9RLS:
10- conversations: users can SELECT conversations they participate in via conversation_participants
11- conversation_participants: users can SELECT their own participant rows
12- messages: users can SELECT messages from conversations they participate in. Users can INSERT to conversations they participate in. Users can UPDATE their own messages only.
13
14Indexes:
15- messages(conversation_id, created_at DESC) fast message feed queries
16- conversation_participants(user_id, last_read_at) fast unread count calculation
17- conversations(last_message_at DESC) sorted conversation list
18
19Trigger: on INSERT to messages, UPDATE conversations.last_message_at = NOW() where id = new.conversation_id

Pro tip: Ask Lovable to create a SECURITY DEFINER function get_unread_count(p_conversation_id uuid) that returns the number of messages created after the current user's last_read_at. Call this function in the conversation list query to get unread counts efficiently without a separate query per conversation.

Expected result: All three tables are created with RLS and indexes. The last_message_at trigger is active. TypeScript types are generated. The app preview loads with no console errors.

2

Build the conversation list sidebar with unread badges

Build the left sidebar showing all conversations sorted by last_message_at. Each row shows the other participant's avatar, the conversation title (or name for group chats), last message preview, and an unread count Badge.

prompt.txt
1Build a chat sidebar component at src/components/ChatSidebar.tsx.
2
3Requirements:
4- Fetch conversations joined with conversation_participants and a subquery for unread_count
5- For 1:1 DMs (title IS NULL), fetch the other participant's profile from auth.users metadata to show their display name and avatar
6- Render each conversation as a clickable row with:
7 - Avatar (other user's avatar for DM, a group icon for group chats)
8 - Conversation title or other user's name
9 - Last message preview (truncated to 40 chars)
10 - Relative timestamp (date-fns formatDistanceToNow)
11 - Unread count Badge (hidden when 0)
12- Sort by last_message_at DESC
13- Clicking a conversation sets it as active and calls update_last_read() to mark all messages as read
14- Add a 'New Chat' Button at the top that opens a Dialog to search for users and start a new DM or group chat
15- Subscribe to postgres_changes on conversation_participants (UPDATE) to refresh unread counts when messages arrive in other conversations

Pro tip: Use optimistic unread count updates: when the user clicks a conversation, immediately set its unread count to 0 in local state before the update_last_read Supabase call completes. This prevents the badge from flashing as the update round-trips.

Expected result: The sidebar renders all conversations with unread badges. Clicking a conversation marks it read and the badge disappears. The conversation list reorders when new messages arrive in other conversations.

3

Build the message feed with Realtime and optimistic UI

Build the main message view with a Realtime subscription for incoming messages and optimistic UI for outgoing messages. Handle the temporary ID swap when the Supabase INSERT confirms.

src/components/MessageFeed.tsx
1// src/components/MessageFeed.tsx
2import { useEffect, useRef, useState, useCallback } from 'react'
3import { supabase } from '@/lib/supabase'
4import { useAuth } from '@/hooks/useAuth'
5import { nanoid } from 'nanoid'
6
7type Message = {
8 id: string
9 user_id: string
10 body: string
11 file_url: string | null
12 created_at: string
13 status?: 'sending' | 'sent' | 'failed'
14}
15
16export function useMessageFeed(conversationId: string | null) {
17 const { user } = useAuth()
18 const [messages, setMessages] = useState<Message[]>([])
19 const [loading, setLoading] = useState(false)
20 const [oldestCursor, setOldestCursor] = useState<string | null>(null)
21 const [hasMore, setHasMore] = useState(true)
22 const bottomRef = useRef<HTMLDivElement>(null)
23
24 const loadMessages = useCallback(async (before?: string) => {
25 if (!conversationId) return
26 setLoading(true)
27 let q = supabase
28 .from('messages')
29 .select('id, user_id, body, file_url, created_at')
30 .eq('conversation_id', conversationId)
31 .order('created_at', { ascending: false })
32 .limit(30)
33 if (before) q = q.lt('created_at', before)
34 const { data } = await q
35 const rows = (data ?? []).reverse()
36 if (before) {
37 setMessages((prev) => [...rows, ...prev])
38 } else {
39 setMessages(rows)
40 setTimeout(() => bottomRef.current?.scrollIntoView(), 50)
41 }
42 setOldestCursor(rows[0]?.created_at ?? null)
43 setHasMore((data?.length ?? 0) === 30)
44 setLoading(false)
45 }, [conversationId])
46
47 useEffect(() => { loadMessages() }, [loadMessages])
48
49 useEffect(() => {
50 if (!conversationId || !user?.id) return
51 const channel = supabase
52 .channel(`chat:${conversationId}`)
53 .on(
54 'postgres_changes',
55 { event: 'INSERT', schema: 'public', table: 'messages', filter: `conversation_id=eq.${conversationId}` },
56 (payload) => {
57 const incoming = payload.new as Message
58 if (incoming.user_id === user.id) return // already added optimistically
59 setMessages((prev) => [...prev, incoming])
60 setTimeout(() => bottomRef.current?.scrollIntoView({ behavior: 'smooth' }), 50)
61 }
62 )
63 .subscribe()
64 return () => { supabase.removeChannel(channel) }
65 }, [conversationId, user?.id])
66
67 const sendMessage = useCallback(async (body: string, fileUrl?: string) => {
68 if (!user?.id || !conversationId) return
69 const tempId = nanoid()
70 const optimistic: Message = {
71 id: tempId, user_id: user.id, body, file_url: fileUrl ?? null,
72 created_at: new Date().toISOString(), status: 'sending'
73 }
74 setMessages((prev) => [...prev, optimistic])
75 setTimeout(() => bottomRef.current?.scrollIntoView({ behavior: 'smooth' }), 50)
76 const { data, error } = await supabase
77 .from('messages')
78 .insert({ conversation_id: conversationId, user_id: user.id, body, file_url: fileUrl })
79 .select('id, created_at')
80 .single()
81 setMessages((prev) =>
82 prev.map((m) => m.id === tempId
83 ? { ...m, id: data?.id ?? tempId, created_at: data?.created_at ?? m.created_at, status: error ? 'failed' : 'sent' }
84 : m
85 )
86 )
87 }, [conversationId, user?.id])
88
89 return { messages, loading, hasMore, loadMore: () => loadMessages(oldestCursor ?? undefined), sendMessage, bottomRef }
90}

Pro tip: Filter out your own messages from the Realtime subscription (if (incoming.user_id === user.id) return). You already added them optimistically. Without this filter, your own messages appear twice — once from optimistic state and once from the Realtime event.

Expected result: Your own messages appear immediately when sent. Other participants' messages appear within 1 second via Realtime. Scrolling to the top of the feed loads older messages.

4

Add typing indicators with Presence

Add a Presence subscription to the conversation channel to broadcast and display typing state. When any participant types, their name appears in a typing indicator below the message feed.

src/hooks/useTypingPresence.ts
1// src/hooks/useTypingPresence.ts
2import { useEffect, useRef, useState } from 'react'
3import { RealtimeChannel } from '@supabase/supabase-js'
4import { supabase } from '@/lib/supabase'
5import { useAuth } from '@/hooks/useAuth'
6
7type PresenceUser = { user_id: string; display_name: string; typing: boolean }
8
9export function useTypingPresence(conversationId: string | null, channel: RealtimeChannel | null) {
10 const { user } = useAuth()
11 const [typingUsers, setTypingUsers] = useState<string[]>([])
12 const typingTimeout = useRef<ReturnType<typeof setTimeout>>()
13
14 useEffect(() => {
15 if (!channel) return
16 channel.on('presence', { event: 'sync' }, () => {
17 const state = channel.presenceState<PresenceUser>()
18 const typing = Object.values(state)
19 .flat()
20 .filter((p) => p.typing && p.user_id !== user?.id)
21 .map((p) => p.display_name)
22 setTypingUsers(typing)
23 })
24 }, [channel, user?.id])
25
26 const onTyping = async () => {
27 if (!channel || !user?.id) return
28 clearTimeout(typingTimeout.current)
29 await channel.track({ user_id: user.id, display_name: user.email ?? 'Someone', typing: true })
30 typingTimeout.current = setTimeout(async () => {
31 await channel.track({ user_id: user.id, display_name: user.email ?? 'Someone', typing: false })
32 }, 3000)
33 }
34
35 const typingLabel = typingUsers.length === 0 ? null
36 : typingUsers.length === 1 ? `${typingUsers[0]} is typing...`
37 : `${typingUsers.slice(0, 2).join(' and ')} are typing...`
38
39 return { typingLabel, onTyping }
40}

Pro tip: Pass the existing Realtime channel object into useTypingPresence rather than creating a new one. This lets you add Presence tracking to the same channel that delivers messages, reducing the total subscription count from 2 to 1 per conversation.

Expected result: When another user types in the conversation, their name appears in the typing indicator below the message feed. It disappears automatically after 3 seconds of inactivity.

5

Add file attachment uploads via Supabase Storage

Add a paperclip button to the message input that opens a file picker. Upload the selected file to Supabase Storage, get the URL, and include it in the message INSERT. Show a thumbnail in the message bubble for images.

prompt.txt
1Add file attachment support to the message input and message bubbles.
2
3Requirements:
4
51. In the message input area, add a paperclip Button that triggers a hidden file input (accept='image/*,application/pdf,.doc,.docx')
6
72. On file selection:
8 - Show a thumbnail preview above the input (for images) or filename chip (for non-images)
9 - Add an 'x' to remove the pending attachment
10 - On send, upload the file to Supabase Storage at path: conversations/{conversationId}/{Date.now()}-{filename}
11 - Use supabase.storage.from('chat-attachments').upload(path, file)
12 - Get the public URL with supabase.storage.from('chat-attachments').getPublicUrl(path)
13 - Pass the public URL as fileUrl to sendMessage()
14
153. In message bubbles, if file_url is set:
16 - If file_type starts with 'image/', show an img tag with max-w-48, rounded corners, cursor-pointer that opens the image in a Dialog on click
17 - Otherwise show a file download link with a file icon, filename, and a download Button
18
194. Create the 'chat-attachments' Supabase Storage bucket with public access for authenticated users. RLS: authenticated users can INSERT, anyone with the URL can SELECT (public bucket).

Pro tip: Show upload progress by using the onUploadProgress callback in the Supabase Storage upload options. Display a progress bar in the attachment preview chip. This prevents users from clicking Send before the upload finishes, which would send a message with a null file_url.

Expected result: Clicking the paperclip opens a file picker. Selecting a file shows a preview above the input. Sending includes the file URL in the message. Images render as thumbnails in the message bubble.

Complete code

src/hooks/useTypingPresence.ts
1import { useEffect, useRef, useState } from 'react'
2import type { RealtimeChannel } from '@supabase/supabase-js'
3import { supabase } from '@/lib/supabase'
4import { useAuth } from '@/hooks/useAuth'
5
6type PresenceUser = { user_id: string; display_name: string; typing: boolean }
7
8export function useTypingPresence(
9 conversationId: string | null,
10 channel: RealtimeChannel | null
11) {
12 const { user } = useAuth()
13 const [typingUsers, setTypingUsers] = useState<string[]>([])
14 const typingTimeout = useRef<ReturnType<typeof setTimeout> | undefined>()
15
16 useEffect(() => {
17 if (!channel || !user?.id) return
18
19 const handleSync = () => {
20 const state = channel.presenceState<PresenceUser>()
21 const typing = Object.values(state)
22 .flat()
23 .filter((p) => p.typing && p.user_id !== user.id)
24 .map((p) => p.display_name)
25 setTypingUsers(typing)
26 }
27
28 channel.on('presence', { event: 'sync' }, handleSync)
29 // No additional cleanup needed — channel is cleaned up by the parent hook
30 }, [channel, user?.id])
31
32 const onTyping = async () => {
33 if (!channel || !user?.id) return
34 clearTimeout(typingTimeout.current)
35 await channel.track({
36 user_id: user.id,
37 display_name: user.email?.split('@')[0] ?? 'Someone',
38 typing: true,
39 })
40 typingTimeout.current = setTimeout(async () => {
41 await channel.track({
42 user_id: user.id,
43 display_name: user.email?.split('@')[0] ?? 'Someone',
44 typing: false,
45 })
46 }, 3000)
47 }
48
49 const stopTyping = async () => {
50 clearTimeout(typingTimeout.current)
51 if (!channel || !user?.id) return
52 await channel.track({
53 user_id: user.id,
54 display_name: user.email?.split('@')[0] ?? 'Someone',
55 typing: false,
56 })
57 }
58
59 const typingLabel =
60 typingUsers.length === 0
61 ? null
62 : typingUsers.length === 1
63 ? `${typingUsers[0]} is typing...`
64 : typingUsers.length === 2
65 ? `${typingUsers[0]} and ${typingUsers[1]} are typing...`
66 : 'Several people are typing...'
67
68 return { typingLabel, onTyping, stopTyping }
69}

Customization ideas

Message reactions with emoji picker

Add a message_reactions table (message_id, user_id, emoji). On message hover, show a '+' Button that opens an emoji picker Popover using the emoji-mart library via esm.sh. Aggregate reactions by emoji and display them as small pill Badges below each message with counts.

Message search across conversations

Add a Search icon in the header that opens a full-page search view. Use Supabase's PostgreSQL full-text search: SELECT * FROM messages WHERE to_tsvector('english', body) @@ plainto_tsquery('english', query) AND conversation_id IN (user's conversations). Highlight matched terms in the results.

Read receipts

Show avatar icons below the last message each participant has read, using their last_read_at from conversation_participants. Subscribe to UPDATE events on conversation_participants to update receipts in real time when other users read new messages.

Message pinning

Add a pinned_message_id column to conversations. A long-press or right-click on a message shows a context menu with a 'Pin message' option. Update the conversations row with the selected message ID. Show the pinned message in a collapsible banner at the top of the message feed.

Push notifications for new messages

Create a push_subscriptions table storing browser PushSubscription objects. When the user grants notification permission, save the subscription. An Edge Function that inserts messages checks if the recipient is currently online via Presence — if not, sends a Web Push notification with the message preview.

Common pitfalls

Pitfall: Not deduplicating messages when the Realtime INSERT fires for your own outgoing message

How to avoid: In the postgres_changes handler, check if the incoming message user_id matches the current user: if (incoming.user_id === user.id) return. Your own messages are already in local state from the optimistic INSERT.

Pitfall: Using a fixed channel name like 'chat' for Realtime

How to avoid: Always include the conversation ID in the channel name: supabase.channel(`chat:${conversationId}`). Change the conversationId dependency in the useEffect to re-subscribe when the user switches conversations.

Pitfall: Loading all messages on conversation open without pagination

How to avoid: Load the most recent 30 messages using .order('created_at', { ascending: false }).limit(30). Reverse them for display. Implement scroll-to-top cursor pagination to load older messages on demand.

Pitfall: Storing file attachments in the messages table body column as base64

How to avoid: Always upload files to Supabase Storage and store only the URL in messages.file_url. Storage handles CDN delivery, access control, and image transformations.

Best practices

  • Filter Realtime callbacks on your own user ID to prevent double-rendering of outgoing messages when using optimistic UI.
  • Include conversation ID in every Realtime channel name to ensure subscriptions are scoped correctly when navigating between conversations.
  • Track Presence state with enough context to render the typing indicator without a secondary user profile fetch: { user_id, display_name, typing }.
  • Always cleanup Realtime channels in useEffect return functions. Each conversation switch creates a new channel — without cleanup, old subscriptions accumulate and exhaust connection limits.
  • Use last_read_at timestamps in conversation_participants instead of integer unread counters. Timestamps are idempotent and stay accurate when multiple tabs are open.
  • Cap the optimistic message list with .slice(-200) if you are not implementing full pagination. This prevents unbounded memory growth in long-lived chat sessions.
  • Add a try/catch around Storage uploads and show a user-facing error Toast if the upload fails. Never silently drop attachment failures — users assume their file was sent.

AI prompts to try

Copy these prompts to build this project faster.

ChatGPT Prompt

I'm building a chat app with optimistic UI in React. When a user sends a message, I add it to state with a temp ID, then replace it with the real database ID after the INSERT confirms. I'm using Supabase Realtime which also fires an INSERT event for my own messages. How do I prevent the message from appearing twice without creating a race condition between the optimistic update and the Realtime event? Show me the useReducer or useState pattern that handles both the temp ID swap and the Realtime deduplication correctly.

Lovable Prompt

Add message search to the chat app. Add a search icon Button in the conversation header. Clicking it slides in a search panel above the message feed with a search Input. As the user types (debounced 300ms), query Supabase using: supabase.from('messages').select().eq('conversation_id', activeConversationId).ilike('body', `%${query}%`).limit(20). Render results as message bubbles with matched text highlighted in yellow. Clicking a result scrolls the message feed to that message's created_at position.

Build Prompt

In Supabase, create a Postgres function get_conversations_with_unread(p_user_id uuid) that returns all conversations the user participates in, with an additional unread_count column. Calculate unread_count as: SELECT COUNT(*) FROM messages WHERE conversation_id = c.id AND created_at > cp.last_read_at AND user_id != p_user_id. Order by last_message_at DESC. Use SECURITY DEFINER so it can read conversation_participants regardless of RLS.

Frequently asked questions

How do I handle the case where a user sends a message and closes the browser before the INSERT confirms?

For most chat apps, losing a message on abrupt close is acceptable. If you need guaranteed delivery, use the notification_queue pattern: insert a row into a queue table immediately (fast), and process it asynchronously via an Edge Function. The message is never lost even if the user closes the browser mid-send.

How do I support group chats versus one-on-one DMs in the same schema?

Use the title column on conversations to distinguish them. DMs have title IS NULL and exactly two rows in conversation_participants. Group chats have a non-null title and any number of participants. When displaying a DM in the sidebar, query the other participant's profile. When displaying a group, use the title field. The message feed works identically for both.

What happens to the Presence state when the user loses internet connection?

Supabase Realtime detects connection loss and fires a channel status event with 'CHANNEL_ERROR' or 'CLOSED'. Add a channel status handler: channel.subscribe((status) => { if (status === 'CLOSED') setOnline(false) }). The Presence state for the disconnected user is automatically removed from the presenceState() object after the heartbeat timeout (about 30 seconds).

Can I use this same pattern for a customer support chat (user ↔ agent)?

Yes. Add a conversation_type column ('user_to_user' | 'support') and a status column ('open' | 'resolved') to conversations. Agents are a separate user group (role in auth.users metadata). RLS policies for support conversations allow agent-role users to read any support conversation, while regular users only see their own.

How does infinite scroll work with Realtime new messages?

Load the initial page anchored to now, newest first, reversed for display. When the user scrolls to the top, fetch messages older than the oldest currently displayed (cursor pagination using .lt('created_at', oldestCreatedAt)). Prepend them to the list. New incoming Realtime messages always append to the bottom of the array — they are never in the pagination cursor range.

How do I show message delivery status (sent, delivered, read)?

Add a status column to messages: 'sent' (default, set by trigger on INSERT) | 'delivered' (set when recipient loads the conversation) | 'read' (set when recipient views the message). Update the recipient's last_read_at in conversation_participants when they open the conversation. Subscribe to UPDATE events on messages to update the sender's UI with tick indicators.

Does Supabase Realtime work reliably on mobile browsers?

Yes, but mobile browsers suspend background tabs and WebSocket connections. When the user returns to the tab, Supabase Realtime automatically reconnects. Add a 'visibilitychange' event listener: on 'visible', fetch messages created since your last received message timestamp to fill the gap that occurred while the tab was suspended.

How does the Presence typing indicator handle users with slow connections?

The 3-second client-side timeout fires a channel.track({ typing: false }) call regardless of network speed. If the track call itself fails due to a slow connection, the typing indicator stays visible until the Presence heartbeat times out (about 30 seconds). For a better experience, also clear the typing indicator locally: set a client-side flag that hides the label after 5 seconds even if no new Presence sync event arrives.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help building your app?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

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.