# How to Build a Chat Application with Lovable

- Tool: How to Build with Lovable
- Difficulty: Advanced
- Compatibility: Lovable Pro or higher
- Last updated: April 2026

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

## Before you start

- 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

## Step-by-step guide

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

```
Create a real-time chat application schema. Tables:

1. 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)

2. 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)

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

RLS:
- conversations: users can SELECT conversations they participate in via conversation_participants
- conversation_participants: users can SELECT their own participant rows
- 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.

Indexes:
- messages(conversation_id, created_at DESC) — fast message feed queries
- conversation_participants(user_id, last_read_at) — fast unread count calculation
- conversations(last_message_at DESC) — sorted conversation list

Trigger: 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.

```
Build a chat sidebar component at src/components/ChatSidebar.tsx.

Requirements:
- Fetch conversations joined with conversation_participants and a subquery for unread_count
- For 1:1 DMs (title IS NULL), fetch the other participant's profile from auth.users metadata to show their display name and avatar
- Render each conversation as a clickable row with:
  - Avatar (other user's avatar for DM, a group icon for group chats)
  - Conversation title or other user's name
  - Last message preview (truncated to 40 chars)
  - Relative timestamp (date-fns formatDistanceToNow)
  - Unread count Badge (hidden when 0)
- Sort by last_message_at DESC
- Clicking a conversation sets it as active and calls update_last_read() to mark all messages as read
- Add a 'New Chat' Button at the top that opens a Dialog to search for users and start a new DM or group chat
- 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
import { useEffect, useRef, useState, useCallback } from 'react'
import { supabase } from '@/lib/supabase'
import { useAuth } from '@/hooks/useAuth'
import { nanoid } from 'nanoid'

type Message = {
  id: string
  user_id: string
  body: string
  file_url: string | null
  created_at: string
  status?: 'sending' | 'sent' | 'failed'
}

export function useMessageFeed(conversationId: string | null) {
  const { user } = useAuth()
  const [messages, setMessages] = useState<Message[]>([])
  const [loading, setLoading] = useState(false)
  const [oldestCursor, setOldestCursor] = useState<string | null>(null)
  const [hasMore, setHasMore] = useState(true)
  const bottomRef = useRef<HTMLDivElement>(null)

  const loadMessages = useCallback(async (before?: string) => {
    if (!conversationId) return
    setLoading(true)
    let q = supabase
      .from('messages')
      .select('id, user_id, body, file_url, created_at')
      .eq('conversation_id', conversationId)
      .order('created_at', { ascending: false })
      .limit(30)
    if (before) q = q.lt('created_at', before)
    const { data } = await q
    const rows = (data ?? []).reverse()
    if (before) {
      setMessages((prev) => [...rows, ...prev])
    } else {
      setMessages(rows)
      setTimeout(() => bottomRef.current?.scrollIntoView(), 50)
    }
    setOldestCursor(rows[0]?.created_at ?? null)
    setHasMore((data?.length ?? 0) === 30)
    setLoading(false)
  }, [conversationId])

  useEffect(() => { loadMessages() }, [loadMessages])

  useEffect(() => {
    if (!conversationId || !user?.id) return
    const channel = supabase
      .channel(`chat:${conversationId}`)
      .on(
        'postgres_changes',
        { event: 'INSERT', schema: 'public', table: 'messages', filter: `conversation_id=eq.${conversationId}` },
        (payload) => {
          const incoming = payload.new as Message
          if (incoming.user_id === user.id) return // already added optimistically
          setMessages((prev) => [...prev, incoming])
          setTimeout(() => bottomRef.current?.scrollIntoView({ behavior: 'smooth' }), 50)
        }
      )
      .subscribe()
    return () => { supabase.removeChannel(channel) }
  }, [conversationId, user?.id])

  const sendMessage = useCallback(async (body: string, fileUrl?: string) => {
    if (!user?.id || !conversationId) return
    const tempId = nanoid()
    const optimistic: Message = {
      id: tempId, user_id: user.id, body, file_url: fileUrl ?? null,
      created_at: new Date().toISOString(), status: 'sending'
    }
    setMessages((prev) => [...prev, optimistic])
    setTimeout(() => bottomRef.current?.scrollIntoView({ behavior: 'smooth' }), 50)
    const { data, error } = await supabase
      .from('messages')
      .insert({ conversation_id: conversationId, user_id: user.id, body, file_url: fileUrl })
      .select('id, created_at')
      .single()
    setMessages((prev) =>
      prev.map((m) => m.id === tempId
        ? { ...m, id: data?.id ?? tempId, created_at: data?.created_at ?? m.created_at, status: error ? 'failed' : 'sent' }
        : m
      )
    )
  }, [conversationId, user?.id])

  return { messages, loading, hasMore, loadMore: () => loadMessages(oldestCursor ?? undefined), sendMessage, bottomRef }
}
```

> 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
import { useEffect, useRef, useState } from 'react'
import { RealtimeChannel } from '@supabase/supabase-js'
import { supabase } from '@/lib/supabase'
import { useAuth } from '@/hooks/useAuth'

type PresenceUser = { user_id: string; display_name: string; typing: boolean }

export function useTypingPresence(conversationId: string | null, channel: RealtimeChannel | null) {
  const { user } = useAuth()
  const [typingUsers, setTypingUsers] = useState<string[]>([])
  const typingTimeout = useRef<ReturnType<typeof setTimeout>>()

  useEffect(() => {
    if (!channel) return
    channel.on('presence', { event: 'sync' }, () => {
      const state = channel.presenceState<PresenceUser>()
      const typing = Object.values(state)
        .flat()
        .filter((p) => p.typing && p.user_id !== user?.id)
        .map((p) => p.display_name)
      setTypingUsers(typing)
    })
  }, [channel, user?.id])

  const onTyping = async () => {
    if (!channel || !user?.id) return
    clearTimeout(typingTimeout.current)
    await channel.track({ user_id: user.id, display_name: user.email ?? 'Someone', typing: true })
    typingTimeout.current = setTimeout(async () => {
      await channel.track({ user_id: user.id, display_name: user.email ?? 'Someone', typing: false })
    }, 3000)
  }

  const typingLabel = typingUsers.length === 0 ? null
    : typingUsers.length === 1 ? `${typingUsers[0]} is typing...`
    : `${typingUsers.slice(0, 2).join(' and ')} are typing...`

  return { typingLabel, onTyping }
}
```

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

```
Add file attachment support to the message input and message bubbles.

Requirements:

1. In the message input area, add a paperclip Button that triggers a hidden file input (accept='image/*,application/pdf,.doc,.docx')

2. On file selection:
   - Show a thumbnail preview above the input (for images) or filename chip (for non-images)
   - Add an 'x' to remove the pending attachment
   - On send, upload the file to Supabase Storage at path: conversations/{conversationId}/{Date.now()}-{filename}
   - Use supabase.storage.from('chat-attachments').upload(path, file)
   - Get the public URL with supabase.storage.from('chat-attachments').getPublicUrl(path)
   - Pass the public URL as fileUrl to sendMessage()

3. In message bubbles, if file_url is set:
   - 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
   - Otherwise show a file download link with a file icon, filename, and a download Button

4. 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 example

File: `src/hooks/useTypingPresence.ts`

```typescript
import { useEffect, useRef, useState } from 'react'
import type { RealtimeChannel } from '@supabase/supabase-js'
import { supabase } from '@/lib/supabase'
import { useAuth } from '@/hooks/useAuth'

type PresenceUser = { user_id: string; display_name: string; typing: boolean }

export function useTypingPresence(
  conversationId: string | null,
  channel: RealtimeChannel | null
) {
  const { user } = useAuth()
  const [typingUsers, setTypingUsers] = useState<string[]>([])
  const typingTimeout = useRef<ReturnType<typeof setTimeout> | undefined>()

  useEffect(() => {
    if (!channel || !user?.id) return

    const handleSync = () => {
      const state = channel.presenceState<PresenceUser>()
      const typing = Object.values(state)
        .flat()
        .filter((p) => p.typing && p.user_id !== user.id)
        .map((p) => p.display_name)
      setTypingUsers(typing)
    }

    channel.on('presence', { event: 'sync' }, handleSync)
    // No additional cleanup needed — channel is cleaned up by the parent hook
  }, [channel, user?.id])

  const onTyping = async () => {
    if (!channel || !user?.id) return
    clearTimeout(typingTimeout.current)
    await channel.track({
      user_id: user.id,
      display_name: user.email?.split('@')[0] ?? 'Someone',
      typing: true,
    })
    typingTimeout.current = setTimeout(async () => {
      await channel.track({
        user_id: user.id,
        display_name: user.email?.split('@')[0] ?? 'Someone',
        typing: false,
      })
    }, 3000)
  }

  const stopTyping = async () => {
    clearTimeout(typingTimeout.current)
    if (!channel || !user?.id) return
    await channel.track({
      user_id: user.id,
      display_name: user.email?.split('@')[0] ?? 'Someone',
      typing: false,
    })
  }

  const typingLabel =
    typingUsers.length === 0
      ? null
      : typingUsers.length === 1
      ? `${typingUsers[0]} is typing...`
      : typingUsers.length === 2
      ? `${typingUsers[0]} and ${typingUsers[1]} are typing...`
      : 'Several people are typing...'

  return { typingLabel, onTyping, stopTyping }
}
```

## Common mistakes

- **Not deduplicating messages when the Realtime INSERT fires for your own outgoing message** — When you INSERT a message, Supabase Realtime fires an event for all subscribers including yourself. Without filtering, your message appears once from optimistic state and a second time from the Realtime event. Fix: 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.
- **Using a fixed channel name like 'chat' for Realtime** — A non-unique channel name causes all conversation subscriptions to share the same channel, delivering messages from all conversations to all views simultaneously. Fix: 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.
- **Loading all messages on conversation open without pagination** — A conversation with thousands of messages will load them all into memory, causing slow initial render and high memory usage on the browser. Fix: 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.
- **Storing file attachments in the messages table body column as base64** — Base64-encoding files in a text column bloats the database, bypasses Supabase Storage CDN, makes Realtime payloads huge, and disables file-level access control. Fix: 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.

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

---

Source: https://www.rapidevelopers.com/how-to-build-lovable/chat-application
© RapidDev — https://www.rapidevelopers.com/how-to-build-lovable/chat-application
