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.
What you're building
A messaging platform differs from a basic chat app in one critical way: users have many simultaneous conversation contexts. A Slack-style platform needs to show unread counts for all channels simultaneously, not just the active one. This requires a subscription management strategy — you cannot open one Realtime channel per conversation for 50 channels without hitting Supabase's connection limit.
The solution is a subscription registry pattern. You maintain a single map of channelId → RealtimeChannel. When a user joins the platform, you open subscriptions only for channels with recent activity (last_message_at within 24 hours). For less-active channels, you poll unread counts on a 60-second interval instead. When the user navigates to an inactive channel, you open its subscription on demand.
The Command palette (Cmd+K) is the discoverability hub. It searches channels by name, users by display name, and recent messages by content. All three search sources are queried in parallel from Supabase when the Command input changes, debounced to 200ms. Results render in sections with keyboard navigation.
DMs use the same messages table as channel messages but reference a dm_conversations table instead of channels. The sidebar shows both in a unified inbox sorted by last_message_at, with unread count badges driven by the last_read_at per-user timestamp pattern.
Final result
A production-ready Slack-style messaging platform with channels, DMs, Command search, Presence typing, and managed Realtime subscriptions.
Tech stack
Prerequisites
- 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
Build steps
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.
1Create a Slack-style messaging platform schema. Tables:231. channels: id, name (text, unique within app), description (text), is_private (bool default false), created_by, last_message_at, created_at452. channel_members: id, channel_id, user_id, last_read_at (default now()), role (text: owner|admin|member), joined_at, UNIQUE(channel_id, user_id)673. 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 duplicates894. dm_participants: id, dm_conversation_id, user_id, last_read_at (default now()), UNIQUE(dm_conversation_id, user_id)10115. 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))12136. user_profiles: id (references auth.users), display_name, avatar_url, status_text (text), is_online (bool default false), updated_at1415RLS:16- channels: SELECT all public channels; SELECT private only if in channel_members17- channel_members: SELECT own rows; INSERT own rows for public channels18- messages: SELECT if user is in channel_members (for channel messages) or dm_participants (for DMs)19- dm_conversations: SELECT if participant_a or participant_b = auth.uid()2021Indexes: 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.
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.
1// src/contexts/SubscriptionContext.tsx2import { createContext, useCallback, useContext, useEffect, useRef } from 'react'3import type { RealtimeChannel } from '@supabase/supabase-js'4import { supabase } from '@/lib/supabase'56type SubscriptionMap = Map<string, RealtimeChannel>7type MessageCallback = (payload: Record<string, unknown>) => void89type SubscriptionContextValue = {10 subscribe: (channelId: string, onMessage: MessageCallback) => void11 unsubscribe: (channelId: string) => void12 getChannel: (channelId: string) => RealtimeChannel | undefined13}1415const SubscriptionContext = createContext<SubscriptionContextValue | null>(null)1617export function SubscriptionProvider({ children }: { children: React.ReactNode }) {18 const subscriptions = useRef<SubscriptionMap>(new Map())1920 const subscribe = useCallback((channelKey: string, onMessage: MessageCallback) => {21 if (subscriptions.current.has(channelKey)) return2223 const [type, id] = channelKey.split(':')24 const table = 'messages'25 const filter = type === 'channel' ? `channel_id=eq.${id}` : `dm_conversation_id=eq.${id}`2627 const channel = supabase28 .channel(`msg:${channelKey}`)29 .on('postgres_changes', { event: 'INSERT', schema: 'public', table, filter }, onMessage)30 .subscribe()3132 subscriptions.current.set(channelKey, channel)33 }, [])3435 const unsubscribe = useCallback((channelKey: string) => {36 const channel = subscriptions.current.get(channelKey)37 if (channel) {38 supabase.removeChannel(channel)39 subscriptions.current.delete(channelKey)40 }41 }, [])4243 const getChannel = useCallback((channelKey: string) => {44 return subscriptions.current.get(channelKey)45 }, [])4647 useEffect(() => {48 return () => {49 subscriptions.current.forEach((ch) => supabase.removeChannel(ch))50 subscriptions.current.clear()51 }52 }, [])5354 return (55 <SubscriptionContext.Provider value={{ subscribe, unsubscribe, getChannel }}>56 {children}57 </SubscriptionContext.Provider>58 )59}6061export const useSubscriptions = () => {62 const ctx = useContext(SubscriptionContext)63 if (!ctx) throw new Error('useSubscriptions must be used inside SubscriptionProvider')64 return ctx65}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.
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.
1Build a global Command palette at src/components/CommandPalette.tsx.23Requirements:4- Trigger with Cmd+K (or Ctrl+K on Windows): use a useEffect with a keydown listener5- Use shadcn/ui CommandDialog wrapping a Command component6- Three CommandGroups: 'Channels', 'Direct Messages', 'Recent Messages'7- Search logic (debounced 200ms, fires when input.length >= 2):8 - Channels: supabase.from('channels').select().ilike('name', `%${query}%`).limit(5)9 - Users: supabase.from('user_profiles').select().ilike('display_name', `%${query}%`).neq('id', userId).limit(5)10 - Messages: supabase.from('messages').select('id, body, channel_id, created_at').ilike('body', `%${query}%`).limit(5)11- Channel result: shows # icon, channel name, member count. Selecting navigates to that channel.12- User result: shows Avatar, display name, online indicator dot. Selecting opens or creates a DM conversation.13- Message result: shows first 60 chars of body, channel name, and timestamp. Selecting navigates to the channel and scrolls to that message.14- Show Skeleton placeholders while searching15- 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.
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.
1// src/hooks/useTypingIndicator.ts2import { useEffect, useRef, useState } from 'react'3import { supabase } from '@/lib/supabase'4import { useSubscriptions } from '@/contexts/SubscriptionContext'5import { useAuth } from '@/hooks/useAuth'67export function useTypingIndicator(channelKey: string | null) {8 const { user } = useAuth()9 const { getChannel } = useSubscriptions()10 const [typingLabels, setTypingLabels] = useState<string[]>([])11 const timeout = useRef<ReturnType<typeof setTimeout>>()12 const presenceChannel = useRef<ReturnType<typeof supabase.channel> | null>(null)1314 useEffect(() => {15 if (!channelKey || !user?.id) return1617 const ch = supabase.channel(`presence:${channelKey}`)18 presenceChannel.current = ch1920 ch.on('presence', { event: 'sync' }, () => {21 type P = { user_id: string; name: string; typing: boolean }22 const state = ch.presenceState<P>()23 const typing = Object.values(state)24 .flat()25 .filter((p) => p.typing && p.user_id !== user.id)26 .map((p) => p.name)27 setTypingLabels(typing)28 })29 .subscribe()3031 return () => { supabase.removeChannel(ch) }32 }, [channelKey, user?.id])3334 const onType = async () => {35 if (!presenceChannel.current || !user?.id) return36 clearTimeout(timeout.current)37 await presenceChannel.current.track({38 user_id: user.id,39 name: user.email?.split('@')[0] ?? 'Someone',40 typing: true,41 })42 timeout.current = setTimeout(async () => {43 await presenceChannel.current?.track({44 user_id: user.id,45 name: user.email?.split('@')[0] ?? 'Someone',46 typing: false,47 })48 }, 3000)49 }5051 const label = typingLabels.length === 0 ? null52 : typingLabels.length === 1 ? `${typingLabels[0]} is typing...`53 : `${typingLabels.slice(0, 2).join(' and ')} are typing...`5455 return { typingLabel: label, onType }56}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.
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.
1Build the messaging sidebar at src/components/MessagingSidebar.tsx.23Requirements:4- Two sections: 'Channels' (hash icon) and 'Direct Messages' (person icon)5- 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.6- Fetch DMs: dm_conversations where participant_a or participant_b = user.id. Join other participant's user_profile. Calculate unread count the same way.7- Sort both sections by last_message_at DESC8- Each row: icon/avatar, name, last message preview (40 chars), relative timestamp, unread Badge9- Unread Badge: show when count > 0, format as number up to 99 then '99+'10- Active channel/DM: highlighted row with bg-muted11- 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.12- When the user clicks a channel/DM, reset its local unread count to 0 and update last_read_at in Supabase.13- Add keyboard navigation: arrow keys move between conversations, Enter opens the selected onePro 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
1import { createContext, useCallback, useContext, useEffect, useRef } from 'react'2import type { RealtimeChannel } from '@supabase/supabase-js'3import { supabase } from '@/lib/supabase'45type SubscriptionMap = Map<string, RealtimeChannel>6type MessageCallback = (payload: Record<string, unknown>) => void78type SubscriptionContextValue = {9 subscribe: (channelKey: string, onMessage: MessageCallback) => RealtimeChannel | null10 unsubscribe: (channelKey: string) => void11 getChannel: (channelKey: string) => RealtimeChannel | undefined12}1314const SubscriptionContext = createContext<SubscriptionContextValue | null>(null)1516export function SubscriptionProvider({ children }: { children: React.ReactNode }) {17 const subscriptions = useRef<SubscriptionMap>(new Map())18 const accessLog = useRef<Map<string, number>>(new Map())19 const MAX_SUBSCRIPTIONS = 202021 const evictOldest = useCallback(() => {22 if (subscriptions.current.size < MAX_SUBSCRIPTIONS) return23 let oldest: string | null = null24 let oldestTime = Infinity25 accessLog.current.forEach((t, key) => {26 if (t < oldestTime) { oldest = key; oldestTime = t }27 })28 if (oldest) {29 const ch = subscriptions.current.get(oldest)30 if (ch) supabase.removeChannel(ch)31 subscriptions.current.delete(oldest)32 accessLog.current.delete(oldest)33 }34 }, [])3536 const subscribe = useCallback((channelKey: string, onMessage: MessageCallback): RealtimeChannel | null => {37 accessLog.current.set(channelKey, Date.now())38 if (subscriptions.current.has(channelKey)) {39 return subscriptions.current.get(channelKey) ?? null40 }41 evictOldest()42 const [type, id] = channelKey.split(':')43 const filter = type === 'channel' ? `channel_id=eq.${id}` : `dm_conversation_id=eq.${id}`44 const channel = supabase45 .channel(`msg:${channelKey}`)46 .on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'messages', filter }, onMessage)47 .subscribe()48 subscriptions.current.set(channelKey, channel)49 return channel50 }, [evictOldest])5152 const unsubscribe = useCallback((channelKey: string) => {53 const ch = subscriptions.current.get(channelKey)54 if (ch) supabase.removeChannel(ch)55 subscriptions.current.delete(channelKey)56 accessLog.current.delete(channelKey)57 }, [])5859 const getChannel = useCallback((channelKey: string) => {60 return subscriptions.current.get(channelKey)61 }, [])6263 useEffect(() => {64 return () => {65 subscriptions.current.forEach((ch) => supabase.removeChannel(ch))66 subscriptions.current.clear()67 }68 }, [])6970 return (71 <SubscriptionContext.Provider value={{ subscribe, unsubscribe, getChannel }}>72 {children}73 </SubscriptionContext.Provider>74 )75}7677export const useSubscriptions = () => {78 const ctx = useContext(SubscriptionContext)79 if (!ctx) throw new Error('useSubscriptions must be inside SubscriptionProvider')80 return ctx81}Customization ideas
Channel message threads
Use the thread_parent_id column on messages to support reply threads. In the message view, show a 'Reply' button on hover. Clicking opens a Sheet panel on the right showing the thread. The thread has its own message input and Realtime subscription filtered by thread_parent_id.
Message scheduling
Add a 'Send later' option to the message input. Store scheduled messages in a scheduled_messages table with send_at timestamptz. A Supabase cron Edge Function runs every minute, finds pending scheduled messages past their send_at, inserts them into messages, and deletes the scheduled row.
Channel analytics for admins
Build an analytics page for channel admins showing messages per day (Recharts BarChart), most active members (sorted message counts), peak activity hours (heatmap), and message length distribution. All data comes from aggregating the messages table with GROUP BY and DATE_TRUNC.
Custom channel notification sounds
Store a notification_sound preference per user in user_profiles. When a Realtime message arrives for a non-active channel, play the selected sound using the Web Audio API. Provide a settings panel with sound preview buttons and a volume slider.
Slash command system
Detect / at the start of the message input and show a Command popup of available slash commands: /giphy, /reminder, /poll. Each command is handled by an Edge Function that processes the input and either posts a formatted message or creates a database record for the feature.
Common pitfalls
Pitfall: Opening one Realtime subscription per channel in the channel list
How to avoid: 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.
Pitfall: Using a duplicate-prone DM conversation creation query
How to avoid: 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.
Pitfall: Tracking Presence state on the messages Realtime channel
How to avoid: 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.
Pitfall: Not handling the case where a DM conversation does not exist yet when loading the DM view
How to avoid: 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.
AI prompts to try
Copy these prompts to build this project faster.
I'm building a Slack-style messaging platform where users can belong to many channels. I want to show unread count badges for all channels in the sidebar without opening a Realtime subscription for every channel. What is the best strategy? Should I poll all channels every 30 seconds, use a single broad Realtime subscription filtered by user_id on the channel_members table, or something else? I'm using Supabase with PostgreSQL. Show me the tradeoffs and a concrete implementation for the approach you recommend.
Add a /poll slash command to the messaging platform. When a user types /poll in the message input, show a popover form with fields: question text, and up to 4 option text inputs. On submit, insert a poll row into a polls table (id, channel_id, created_by, question, options jsonb array, ends_at). Insert a message into the channel with metadata: { type: 'poll', poll_id }. In the message renderer, detect this metadata and render a PollMessage component showing the question, options as Buttons, current vote counts, and a progress bar per option.
In Supabase, create a function get_inbox(p_user_id uuid) that returns a JSON array of inbox items. Each item has: id, type ('channel' or 'dm'), name (channel name or other user's display_name), avatar_url, last_message_preview (first 60 chars of the latest message body), last_message_at, unread_count (messages created after p_user_id's last_read_at). Return channels from channel_members JOIN channels and DMs from dm_conversations JOIN user_profiles, all sorted by last_message_at DESC, combined with UNION ALL.
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation