# How to Build a Messaging Platform with Lovable

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

## TL;DR

Build a Slack-style messaging platform in Lovable with public channels, private DMs, channel search via Command, and multiple simultaneous Realtime subscriptions. Presence tracks who is online and powers typing indicators. Users switch channels without interrupting active subscriptions — all backed by Supabase with no separate server.

## Before you start

- Lovable Pro account with Supabase Realtime access
- Supabase project with URL, anon key, and service role key in Cloud tab → Secrets
- Supabase Auth with multiple test accounts for multi-user testing
- Understanding of Supabase Realtime connection limits (200 on Free, 500 on Pro)
- Basic React Context knowledge — this build uses a subscription registry pattern

## Step-by-step guide

### 1. Design the messaging schema with unified inbox support

Create all tables for channels, DMs, messages, and membership. The schema uses a single messages table with two nullable foreign keys (channel_id and dm_conversation_id) to support a unified feed query.

```
Create a Slack-style messaging platform schema. Tables:

1. channels: id, name (text, unique within app), description (text), is_private (bool default false), created_by, last_message_at, created_at

2. channel_members: id, channel_id, user_id, last_read_at (default now()), role (text: owner|admin|member), joined_at, UNIQUE(channel_id, user_id)

3. dm_conversations: id, participant_a (references auth.users), participant_b (references auth.users), last_message_at, created_at, CHECK(participant_a < participant_b) to prevent duplicates

4. dm_participants: id, dm_conversation_id, user_id, last_read_at (default now()), UNIQUE(dm_conversation_id, user_id)

5. messages: id, channel_id (references channels, nullable), dm_conversation_id (references dm_conversations, nullable), thread_parent_id (references messages, nullable), user_id, body (text), metadata (jsonb default '{}'), created_at, edited_at, CHECK((channel_id IS NOT NULL) != (dm_conversation_id IS NOT NULL))

6. user_profiles: id (references auth.users), display_name, avatar_url, status_text (text), is_online (bool default false), updated_at

RLS:
- channels: SELECT all public channels; SELECT private only if in channel_members
- channel_members: SELECT own rows; INSERT own rows for public channels
- messages: SELECT if user is in channel_members (for channel messages) or dm_participants (for DMs)
- dm_conversations: SELECT if participant_a or participant_b = auth.uid()

Indexes: messages(channel_id, created_at DESC), messages(dm_conversation_id, created_at DESC), messages(thread_parent_id, created_at ASC)
```

> Pro tip: The CHECK constraint on dm_conversations (participant_a < participant_b) prevents duplicate DM threads between the same two users. When creating a DM, always insert with the smaller UUID as participant_a. Use a SECURITY DEFINER function get_or_create_dm(user_a, user_b) that handles the ordering automatically.

**Expected result:** All tables created with RLS, indexes, and CHECK constraints. TypeScript types generated. The app preview loads without errors.

### 2. Build the subscription registry for multi-channel Realtime

Create a React Context that manages a map of active Realtime subscriptions. This prevents hitting the Supabase connection limit when a user has access to many channels, and ensures clean cleanup when channels are unmounted.

```
// src/contexts/SubscriptionContext.tsx
import { createContext, useCallback, useContext, useEffect, useRef } from 'react'
import type { RealtimeChannel } from '@supabase/supabase-js'
import { supabase } from '@/lib/supabase'

type SubscriptionMap = Map<string, RealtimeChannel>
type MessageCallback = (payload: Record<string, unknown>) => void

type SubscriptionContextValue = {
  subscribe: (channelId: string, onMessage: MessageCallback) => void
  unsubscribe: (channelId: string) => void
  getChannel: (channelId: string) => RealtimeChannel | undefined
}

const SubscriptionContext = createContext<SubscriptionContextValue | null>(null)

export function SubscriptionProvider({ children }: { children: React.ReactNode }) {
  const subscriptions = useRef<SubscriptionMap>(new Map())

  const subscribe = useCallback((channelKey: string, onMessage: MessageCallback) => {
    if (subscriptions.current.has(channelKey)) return

    const [type, id] = channelKey.split(':')
    const table = 'messages'
    const filter = type === 'channel' ? `channel_id=eq.${id}` : `dm_conversation_id=eq.${id}`

    const channel = supabase
      .channel(`msg:${channelKey}`)
      .on('postgres_changes', { event: 'INSERT', schema: 'public', table, filter }, onMessage)
      .subscribe()

    subscriptions.current.set(channelKey, channel)
  }, [])

  const unsubscribe = useCallback((channelKey: string) => {
    const channel = subscriptions.current.get(channelKey)
    if (channel) {
      supabase.removeChannel(channel)
      subscriptions.current.delete(channelKey)
    }
  }, [])

  const getChannel = useCallback((channelKey: string) => {
    return subscriptions.current.get(channelKey)
  }, [])

  useEffect(() => {
    return () => {
      subscriptions.current.forEach((ch) => supabase.removeChannel(ch))
      subscriptions.current.clear()
    }
  }, [])

  return (
    <SubscriptionContext.Provider value={{ subscribe, unsubscribe, getChannel }}>
      {children}
    </SubscriptionContext.Provider>
  )
}

export const useSubscriptions = () => {
  const ctx = useContext(SubscriptionContext)
  if (!ctx) throw new Error('useSubscriptions must be used inside SubscriptionProvider')
  return ctx
}
```

> Pro tip: Limit the subscription registry to a maximum of 20 active channels. When subscribing to a new channel would exceed 20, unsubscribe the oldest inactive channel first. Track last-accessed timestamps per channel key in a separate Map to determine which to evict.

**Expected result:** The SubscriptionProvider wraps the app. Channels are subscribed once and reused across component re-renders. Navigating between channels adds new subscriptions without creating duplicates.

### 3. Build the Command palette for channel and user search

Implement the Cmd+K Command palette using shadcn/ui Command. It searches channels by name and users by display name, showing results in grouped sections. Selecting a result navigates to the channel or opens a DM.

```
Build a global Command palette at src/components/CommandPalette.tsx.

Requirements:
- Trigger with Cmd+K (or Ctrl+K on Windows): use a useEffect with a keydown listener
- Use shadcn/ui CommandDialog wrapping a Command component
- Three CommandGroups: 'Channels', 'Direct Messages', 'Recent Messages'
- Search logic (debounced 200ms, fires when input.length >= 2):
  - Channels: supabase.from('channels').select().ilike('name', `%${query}%`).limit(5)
  - Users: supabase.from('user_profiles').select().ilike('display_name', `%${query}%`).neq('id', userId).limit(5)
  - Messages: supabase.from('messages').select('id, body, channel_id, created_at').ilike('body', `%${query}%`).limit(5)
- Channel result: shows # icon, channel name, member count. Selecting navigates to that channel.
- User result: shows Avatar, display name, online indicator dot. Selecting opens or creates a DM conversation.
- Message result: shows first 60 chars of body, channel name, and timestamp. Selecting navigates to the channel and scrolls to that message.
- Show Skeleton placeholders while searching
- Show keyboard shortcut hint: 'Press Enter to open, Esc to close'
```

> Pro tip: Run the three search queries in parallel with Promise.all() rather than sequentially. This cuts the search latency from the sum of all three queries to the time of the slowest one — typically 50-100ms instead of 150-300ms.

**Expected result:** Pressing Cmd+K opens the Command palette. Typing a query shows matching channels, users, and messages in grouped results. Selecting a channel navigates to it and closes the palette.

### 4. Add Presence-based typing indicators

Add typing indicator support to channel and DM views. When a user types, broadcast their typing state via Presence on the channel's Realtime subscription. The typing label updates when any participant's state changes.

```
// src/hooks/useTypingIndicator.ts
import { useEffect, useRef, useState } from 'react'
import { supabase } from '@/lib/supabase'
import { useSubscriptions } from '@/contexts/SubscriptionContext'
import { useAuth } from '@/hooks/useAuth'

export function useTypingIndicator(channelKey: string | null) {
  const { user } = useAuth()
  const { getChannel } = useSubscriptions()
  const [typingLabels, setTypingLabels] = useState<string[]>([])
  const timeout = useRef<ReturnType<typeof setTimeout>>()
  const presenceChannel = useRef<ReturnType<typeof supabase.channel> | null>(null)

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

    const ch = supabase.channel(`presence:${channelKey}`)
    presenceChannel.current = ch

    ch.on('presence', { event: 'sync' }, () => {
      type P = { user_id: string; name: string; typing: boolean }
      const state = ch.presenceState<P>()
      const typing = Object.values(state)
        .flat()
        .filter((p) => p.typing && p.user_id !== user.id)
        .map((p) => p.name)
      setTypingLabels(typing)
    })
    .subscribe()

    return () => { supabase.removeChannel(ch) }
  }, [channelKey, user?.id])

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

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

  return { typingLabel: label, onType }
}
```

> Pro tip: Keep the Presence subscription separate from the messages subscription. The messages subscription is managed by the SubscriptionRegistry. The Presence subscription is created and destroyed with the active channel view. This separation prevents Presence state from leaking into the subscription map and keeps the registry size predictable.

**Expected result:** When another user types in the active channel, their name appears in the typing indicator below the message feed. The indicator clears automatically 3 seconds after they stop typing.

### 5. Build the unified inbox sidebar with unread badges

Build the full sidebar showing channels and DMs together, sorted by last_message_at. Each item shows the last message preview and an unread count Badge. The Badge updates in real time when new messages arrive.

```
Build the messaging sidebar at src/components/MessagingSidebar.tsx.

Requirements:
- Two sections: 'Channels' (hash icon) and 'Direct Messages' (person icon)
- Fetch channels: joined channels from channel_members + user's profile for each. For each channel, calculate unread_count = messages created after current user's last_read_at in that channel.
- Fetch DMs: dm_conversations where participant_a or participant_b = user.id. Join other participant's user_profile. Calculate unread count the same way.
- Sort both sections by last_message_at DESC
- Each row: icon/avatar, name, last message preview (40 chars), relative timestamp, unread Badge
- Unread Badge: show when count > 0, format as number up to 99 then '99+'
- Active channel/DM: highlighted row with bg-muted
- Subscribe to postgres_changes INSERT on messages for all channels in the list (use the SubscriptionRegistry). When a message arrives for a non-active channel, increment its local unread count.
- When the user clicks a channel/DM, reset its local unread count to 0 and update last_read_at in Supabase.
- Add keyboard navigation: arrow keys move between conversations, Enter opens the selected one
```

> Pro tip: Batch the unread count queries using a single Supabase RPC function get_inbox(p_user_id) rather than a query per channel. This reduces the initial load from N+1 queries to one. The function returns both channels and DMs with their unread counts in a single JSON response.

**Expected result:** The sidebar shows all channels and DMs with unread badges. New messages in background channels increment their badge without requiring a page refresh. Clicking a channel resets its badge.

## Complete code example

File: `src/contexts/SubscriptionContext.tsx`

```typescript
import { createContext, useCallback, useContext, useEffect, useRef } from 'react'
import type { RealtimeChannel } from '@supabase/supabase-js'
import { supabase } from '@/lib/supabase'

type SubscriptionMap = Map<string, RealtimeChannel>
type MessageCallback = (payload: Record<string, unknown>) => void

type SubscriptionContextValue = {
  subscribe: (channelKey: string, onMessage: MessageCallback) => RealtimeChannel | null
  unsubscribe: (channelKey: string) => void
  getChannel: (channelKey: string) => RealtimeChannel | undefined
}

const SubscriptionContext = createContext<SubscriptionContextValue | null>(null)

export function SubscriptionProvider({ children }: { children: React.ReactNode }) {
  const subscriptions = useRef<SubscriptionMap>(new Map())
  const accessLog = useRef<Map<string, number>>(new Map())
  const MAX_SUBSCRIPTIONS = 20

  const evictOldest = useCallback(() => {
    if (subscriptions.current.size < MAX_SUBSCRIPTIONS) return
    let oldest: string | null = null
    let oldestTime = Infinity
    accessLog.current.forEach((t, key) => {
      if (t < oldestTime) { oldest = key; oldestTime = t }
    })
    if (oldest) {
      const ch = subscriptions.current.get(oldest)
      if (ch) supabase.removeChannel(ch)
      subscriptions.current.delete(oldest)
      accessLog.current.delete(oldest)
    }
  }, [])

  const subscribe = useCallback((channelKey: string, onMessage: MessageCallback): RealtimeChannel | null => {
    accessLog.current.set(channelKey, Date.now())
    if (subscriptions.current.has(channelKey)) {
      return subscriptions.current.get(channelKey) ?? null
    }
    evictOldest()
    const [type, id] = channelKey.split(':')
    const filter = type === 'channel' ? `channel_id=eq.${id}` : `dm_conversation_id=eq.${id}`
    const channel = supabase
      .channel(`msg:${channelKey}`)
      .on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'messages', filter }, onMessage)
      .subscribe()
    subscriptions.current.set(channelKey, channel)
    return channel
  }, [evictOldest])

  const unsubscribe = useCallback((channelKey: string) => {
    const ch = subscriptions.current.get(channelKey)
    if (ch) supabase.removeChannel(ch)
    subscriptions.current.delete(channelKey)
    accessLog.current.delete(channelKey)
  }, [])

  const getChannel = useCallback((channelKey: string) => {
    return subscriptions.current.get(channelKey)
  }, [])

  useEffect(() => {
    return () => {
      subscriptions.current.forEach((ch) => supabase.removeChannel(ch))
      subscriptions.current.clear()
    }
  }, [])

  return (
    <SubscriptionContext.Provider value={{ subscribe, unsubscribe, getChannel }}>
      {children}
    </SubscriptionContext.Provider>
  )
}

export const useSubscriptions = () => {
  const ctx = useContext(SubscriptionContext)
  if (!ctx) throw new Error('useSubscriptions must be inside SubscriptionProvider')
  return ctx
}
```

## Common mistakes

- **Opening one Realtime subscription per channel in the channel list** — If a user has access to 50 channels and you subscribe to all of them on sidebar load, you immediately use 50 of your 200 Supabase Realtime connections. Other users cannot connect. Fix: Use the SubscriptionRegistry pattern to cap active subscriptions at 20. Subscribe eagerly only to the 5 most recent channels. Subscribe on-demand when the user navigates to a less-active channel.
- **Using a duplicate-prone DM conversation creation query** — If two users both click 'Start DM' simultaneously, you get two dm_conversations rows between them with duplicate participant IDs. Fix: Enforce uniqueness with the CHECK(participant_a < participant_b) constraint and a UNIQUE(participant_a, participant_b) index. Use a SECURITY DEFINER get_or_create_dm(a, b) function that sorts IDs before inserting, using ON CONFLICT DO NOTHING.
- **Tracking Presence state on the messages Realtime channel** — If you add Presence tracking to the same channel used for postgres_changes, Supabase treats the channel as 'presence-enabled' which changes its behavior. Presence channels have different heartbeat and cleanup semantics than postgres_changes channels. Fix: Use separate Supabase channels for postgres_changes (messages) and Presence (typing). The SubscriptionRegistry manages message channels; a separate useTypingIndicator hook manages its own Presence channel with independent lifecycle.
- **Not handling the case where a DM conversation does not exist yet when loading the DM view** — Clicking on a user in the directory should show the DM view even before any message is sent. If you try to subscribe to a dm_conversation_id that does not exist, the Realtime filter returns no events. Fix: Call get_or_create_dm(userId, otherUserId) before loading the DM view. This function returns the existing conversation ID or creates a new one. Subscribe to messages using the returned ID.

## Best practices

- Cap active Realtime subscriptions at 20 per client using a registry with LRU eviction. Supabase Free tier limits concurrent connections to 200 across all users.
- Use separate Realtime channels for postgres_changes and Presence to avoid mixing heartbeat semantics.
- Namespace all Realtime channel names with a prefix and the resource ID: 'msg:channel:abc123', 'presence:channel:abc123'. This prevents accidental cross-resource event delivery.
- Calculate unread counts server-side in a single RPC function rather than client-side across multiple queries. N+1 unread count queries on sidebar load are a common performance bottleneck.
- Debounce Command palette search to 200ms and run all three search queries (channels, users, messages) in parallel with Promise.all to keep search feel responsive.
- Store user display names and avatar URLs in a user_profiles table, not in auth.users metadata. user_profiles can have RLS policies and is queryable via normal Supabase client without admin keys.
- Add a last_message_at trigger on the channels table so the sidebar sort order stays accurate without a separate UPDATE call from the client after each message.

## Frequently asked questions

### How do I prevent Supabase Realtime connection limit issues when many users are online?

Supabase Free plan allows 200 concurrent Realtime connections total across all users. If you have 200 users online each with 1 subscription, you hit the limit. Upgrade to Pro (500 connections) for modest scale, or implement connection sharing: instead of each user subscribing to individual channels, subscribe to a single broader channel filtered by user_id and route messages client-side.

### How do I handle messages sent while a user was offline?

When the user opens the app or returns to a channel, fetch messages created after their last_read_at timestamp from the database. This fills the gap between when they went offline and now. The Realtime subscription only delivers events that occur while the subscription is active — it does not replay missed events from when the connection was closed.

### Can the Command palette search message content across all channels?

Yes, but add a full-text search index for performance: CREATE INDEX ON messages USING gin(to_tsvector('english', body)). Then query with: .textSearch('body', query). Without the index, ilike searches scan the full messages table which gets slow above 100,000 rows. The gin index makes full-text search fast regardless of table size.

### How do I handle message editing and deletion?

Add an is_deleted (bool) and edited_at (timestamptz) column to messages. For edits: UPDATE messages SET body = new_body, edited_at = now() WHERE id = message_id AND user_id = auth.uid(). For deletes: UPDATE messages SET is_deleted = true WHERE id = message_id AND user_id = auth.uid(). Subscribe to Realtime UPDATE events and update local state when these events arrive. Show 'edited' and 'deleted' labels in the UI based on these fields.

### How do I make the messaging platform work well on mobile browsers?

Mobile browsers suspend WebSocket connections when the app is in the background. Add a visibilitychange listener that re-fetches messages since the last received timestamp when the page becomes visible again. Also adjust the message input for mobile: use a Textarea instead of an Input so it supports multi-line entry with a send Button below rather than Enter key submission.

### What is the performance impact of having many channels in the sidebar?

The sidebar query calls get_inbox() which runs one server-side aggregation. This is fast even with 100+ channels because it uses the indexed last_message_at and last_read_at columns. The Realtime subscription registry caps active subscriptions at 20, so the connection count does not grow with the channel count. Pagination or virtual scrolling is only needed if you expect users with more than 500 channels.

### How do I add file sharing to DMs and channels?

Add file_url, file_name, and file_type columns to messages. Upload files to a Supabase Storage bucket before inserting the message. Use a per-conversation bucket path (channels/channel_id/timestamp-filename) for organized storage. In the message renderer, detect file_type to show image thumbnails, PDF icons, or generic download links. Add a Paperclip icon button to the message input that triggers a file picker.

### Can RapidDev help add video calling or screen sharing to this platform?

Yes. RapidDev integrates third-party WebRTC services like Daily.co or Livekit into Lovable apps. The messaging platform would get a 'Start call' button that creates a video room via an Edge Function and signals other participants via Supabase Realtime Broadcast. Reach out for help with real-time audio/video features.

---

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