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
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
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.
1Create a real-time chat application schema. Tables:231. 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)452. 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)673. 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_at89RLS:10- conversations: users can SELECT conversations they participate in via conversation_participants11- conversation_participants: users can SELECT their own participant rows12- 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.1314Indexes:15- messages(conversation_id, created_at DESC) — fast message feed queries16- conversation_participants(user_id, last_read_at) — fast unread count calculation17- conversations(last_message_at DESC) — sorted conversation list1819Trigger: on INSERT to messages, UPDATE conversations.last_message_at = NOW() where id = new.conversation_idPro 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.
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.
1Build a chat sidebar component at src/components/ChatSidebar.tsx.23Requirements:4- Fetch conversations joined with conversation_participants and a subquery for unread_count5- For 1:1 DMs (title IS NULL), fetch the other participant's profile from auth.users metadata to show their display name and avatar6- 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 name9 - 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 DESC13- Clicking a conversation sets it as active and calls update_last_read() to mark all messages as read14- Add a 'New Chat' Button at the top that opens a Dialog to search for users and start a new DM or group chat15- Subscribe to postgres_changes on conversation_participants (UPDATE) to refresh unread counts when messages arrive in other conversationsPro 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.
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.
1// src/components/MessageFeed.tsx2import { useEffect, useRef, useState, useCallback } from 'react'3import { supabase } from '@/lib/supabase'4import { useAuth } from '@/hooks/useAuth'5import { nanoid } from 'nanoid'67type Message = {8 id: string9 user_id: string10 body: string11 file_url: string | null12 created_at: string13 status?: 'sending' | 'sent' | 'failed'14}1516export 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)2324 const loadMessages = useCallback(async (before?: string) => {25 if (!conversationId) return26 setLoading(true)27 let q = supabase28 .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 q35 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])4647 useEffect(() => { loadMessages() }, [loadMessages])4849 useEffect(() => {50 if (!conversationId || !user?.id) return51 const channel = supabase52 .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 Message58 if (incoming.user_id === user.id) return // already added optimistically59 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])6667 const sendMessage = useCallback(async (body: string, fileUrl?: string) => {68 if (!user?.id || !conversationId) return69 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 supabase77 .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 === tempId83 ? { ...m, id: data?.id ?? tempId, created_at: data?.created_at ?? m.created_at, status: error ? 'failed' : 'sent' }84 : m85 )86 )87 }, [conversationId, user?.id])8889 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.
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.
1// src/hooks/useTypingPresence.ts2import { useEffect, useRef, useState } from 'react'3import { RealtimeChannel } from '@supabase/supabase-js'4import { supabase } from '@/lib/supabase'5import { useAuth } from '@/hooks/useAuth'67type PresenceUser = { user_id: string; display_name: string; typing: boolean }89export 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>>()1314 useEffect(() => {15 if (!channel) return16 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])2526 const onTyping = async () => {27 if (!channel || !user?.id) return28 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 }3435 const typingLabel = typingUsers.length === 0 ? null36 : typingUsers.length === 1 ? `${typingUsers[0]} is typing...`37 : `${typingUsers.slice(0, 2).join(' and ')} are typing...`3839 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.
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.
1Add file attachment support to the message input and message bubbles.23Requirements:451. In the message input area, add a paperclip Button that triggers a hidden file input (accept='image/*,application/pdf,.doc,.docx')672. 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 attachment10 - 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()14153. 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 click17 - Otherwise show a file download link with a file icon, filename, and a download Button18194. 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
1import { useEffect, useRef, useState } from 'react'2import type { RealtimeChannel } from '@supabase/supabase-js'3import { supabase } from '@/lib/supabase'4import { useAuth } from '@/hooks/useAuth'56type PresenceUser = { user_id: string; display_name: string; typing: boolean }78export function useTypingPresence(9 conversationId: string | null,10 channel: RealtimeChannel | null11) {12 const { user } = useAuth()13 const [typingUsers, setTypingUsers] = useState<string[]>([])14 const typingTimeout = useRef<ReturnType<typeof setTimeout> | undefined>()1516 useEffect(() => {17 if (!channel || !user?.id) return1819 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 }2728 channel.on('presence', { event: 'sync' }, handleSync)29 // No additional cleanup needed — channel is cleaned up by the parent hook30 }, [channel, user?.id])3132 const onTyping = async () => {33 if (!channel || !user?.id) return34 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 }4849 const stopTyping = async () => {50 clearTimeout(typingTimeout.current)51 if (!channel || !user?.id) return52 await channel.track({53 user_id: user.id,54 display_name: user.email?.split('@')[0] ?? 'Someone',55 typing: false,56 })57 }5859 const typingLabel =60 typingUsers.length === 061 ? null62 : typingUsers.length === 163 ? `${typingUsers[0]} is typing...`64 : typingUsers.length === 265 ? `${typingUsers[0]} and ${typingUsers[1]} are typing...`66 : 'Several people are typing...'6768 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.
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.
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.
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation