Build a real-time messaging platform with V0 using Next.js, Supabase Realtime, and shadcn/ui. Features public and private channels, direct messages, live typing indicators, read receipts, and file attachments — with cursor-based pagination for performant message history loading. Takes about 2-4 hours.
What you're building
Teams and communities need messaging with channels, DMs, and real-time delivery. Building a Slack-style platform requires careful attention to real-time subscriptions, efficient message loading, and presence tracking.
V0 generates the chat layout, message components, and channel management from prompts. Supabase Realtime provides the WebSocket infrastructure for instant message delivery and presence tracking. The Connect panel sets up both the database and Realtime in one click.
The architecture uses Supabase Realtime postgres_changes for message delivery, Broadcast for ephemeral events (typing indicators, presence), cursor-based pagination for message history, and optimistic UI for instant feedback when sending messages.
Final result
A real-time messaging platform with channels, DMs, typing indicators, read receipts, file attachments, and infinite scroll message history.
Tech stack
Prerequisites
- A V0 account (Premium or higher — this is a complex build)
- A Supabase project with Realtime enabled (free tier works but Pro recommended for production)
- Understanding of your channel structure (public, private, DM)
- Test users for trying real-time features
Build steps
Set up the database schema for channels, messages, and presence
Create the Supabase schema with channels, memberships, messages, reactions, and presence tracking. Enable Realtime on the messages table for live updates.
1// Paste this prompt into V0's AI chat:2// Build a messaging platform. Create a Supabase schema:3// 1. channels: id (uuid PK), name (text), description (text), type (text CHECK IN 'public','private','dm'), created_by (uuid FK to auth.users), created_at (timestamptz)4// 2. channel_members: channel_id (uuid FK to channels), user_id (uuid FK to auth.users), role (text DEFAULT 'member'), joined_at (timestamptz), last_read_at (timestamptz), PRIMARY KEY (channel_id, user_id)5// 3. messages: id (uuid PK), channel_id (uuid FK to channels), sender_id (uuid FK to auth.users), content (text), attachments (text[]), parent_id (uuid FK to messages NULL for threads), is_edited (boolean DEFAULT false), created_at (timestamptz)6// 4. reactions: id (uuid PK), message_id (uuid FK to messages), user_id (uuid FK to auth.users), emoji (text)7// 5. user_presence: user_id (uuid PK FK to auth.users), status (text DEFAULT 'offline'), last_seen (timestamptz)8// Enable Realtime on the messages table. Add RLS policies: members can read/write in channels they belong to.Pro tip: Enable Realtime for the messages table in Supabase Dashboard under Database > Replication. On the free tier, verify it is enabled; on Pro, it is on by default.
Expected result: All tables are created with RLS policies. Realtime is enabled on the messages table for live subscriptions.
Build the chat layout with channel sidebar and message area
Create the main chat page with a sidebar listing channels and a main area for messages. The sidebar shows channel names with unread counts, and the message area displays the conversation.
1// Paste this prompt into V0's AI chat:2// Create a messaging layout at app/chat/page.tsx.3// Requirements:4// - Two-column layout: sidebar (25%) + main message area (75%)5// - Sidebar: list of channels with channel name, type icon (hash for public, lock for private, user for DM), unread Badge count6// - Channel list grouped by type: Channels (public/private), Direct Messages7// - Active channel highlighted with accent background8// - Add a 'New Channel' Button that opens a Dialog (name Input, description Textarea, type RadioGroup)9// - Main area: channel name header, message ScrollArea, input composer at bottom10// - Message composer: Input with send Button, attachment upload Button, emoji Popover11// - Use shadcn/ui ScrollArea for messages, Badge for unread counts, Avatar for user icons, ContextMenu for message actions12// - Mobile: sidebar collapses into a Sheet triggered by hamburger menuExpected result: The chat interface shows a channel sidebar with unread counts and a message area with a composer input.
Implement real-time message delivery with Supabase Realtime
Create the client component that subscribes to new messages in the current channel, handles typing indicators via Broadcast, and manages online presence. New messages are prepended to the local state for instant display.
1'use client'23import { useEffect, useState, useRef } from 'react'4import { createBrowserClient } from '@supabase/ssr'5import { ScrollArea } from '@/components/ui/scroll-area'6import { Avatar, AvatarFallback } from '@/components/ui/avatar'78interface Message {9 id: string10 content: string11 sender_id: string12 sender_name: string13 created_at: string14 attachments: string[]15}1617export function MessageList({ channelId, initialMessages }: {18 channelId: string19 initialMessages: Message[]20}) {21 const [messages, setMessages] = useState(initialMessages)22 const bottomRef = useRef<HTMLDivElement>(null)23 const supabase = createBrowserClient(24 process.env.NEXT_PUBLIC_SUPABASE_URL!,25 process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!26 )2728 useEffect(() => {29 const channel = supabase30 .channel(`messages:${channelId}`)31 .on('postgres_changes', {32 event: 'INSERT',33 schema: 'public',34 table: 'messages',35 filter: `channel_id=eq.${channelId}`,36 }, (payload) => {37 setMessages((prev) => [...prev, payload.new as Message])38 bottomRef.current?.scrollIntoView({ behavior: 'smooth' })39 })40 .subscribe()4142 return () => { supabase.removeChannel(channel) }43 }, [channelId, supabase])4445 return (46 <ScrollArea className="flex-1 p-4">47 {messages.map((msg) => (48 <div key={msg.id} className="flex gap-3 mb-4">49 <Avatar><AvatarFallback>{msg.sender_name[0]}</AvatarFallback></Avatar>50 <div>51 <p className="text-sm font-medium">{msg.sender_name}</p>52 <p className="text-sm">{msg.content}</p>53 </div>54 </div>55 ))}56 <div ref={bottomRef} />57 </ScrollArea>58 )59}Expected result: Messages appear instantly when sent by any user in the channel. The scroll area auto-scrolls to new messages.
Add typing indicators and presence with Supabase Broadcast
Implement ephemeral typing indicators and online/offline presence using Supabase Broadcast channels. These events are not persisted to the database.
1'use client'23import { useEffect, useState } from 'react'4import { createBrowserClient } from '@supabase/ssr'5import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'67export function TypingIndicator({ channelId, userId }: {8 channelId: string9 userId: string10}) {11 const [typingUsers, setTypingUsers] = useState<string[]>([])12 const supabase = createBrowserClient(13 process.env.NEXT_PUBLIC_SUPABASE_URL!,14 process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!15 )1617 useEffect(() => {18 const channel = supabase.channel(`typing:${channelId}`)19 .on('broadcast', { event: 'typing' }, ({ payload }) => {20 if (payload.user_id !== userId) {21 setTypingUsers((prev) =>22 prev.includes(payload.user_name) ? prev : [...prev, payload.user_name]23 )24 setTimeout(() => {25 setTypingUsers((prev) => prev.filter((u) => u !== payload.user_name))26 }, 3000)27 }28 })29 .subscribe()3031 return () => { supabase.removeChannel(channel) }32 }, [channelId, userId, supabase])3334 if (typingUsers.length === 0) return null3536 return (37 <Tooltip>38 <TooltipTrigger asChild>39 <p className="text-xs text-muted-foreground animate-pulse">40 {typingUsers.join(', ')} {typingUsers.length === 1 ? 'is' : 'are'} typing...41 </p>42 </TooltipTrigger>43 <TooltipContent>Active users in this channel</TooltipContent>44 </Tooltip>45 )46}4748export function sendTypingEvent(49 supabase: ReturnType<typeof createBrowserClient>,50 channelId: string,51 userId: string,52 userName: string53) {54 supabase.channel(`typing:${channelId}`).send({55 type: 'broadcast',56 event: 'typing',57 payload: { user_id: userId, user_name: userName },58 })59}Expected result: Typing indicators show below the message area when other users are typing. The indicator disappears after 3 seconds of inactivity.
Implement cursor-based pagination and message sending
Add infinite scroll for message history using cursor-based pagination and the message sending API with optimistic UI updates.
1import { createClient } from '@supabase/supabase-js'2import { NextRequest, NextResponse } from 'next/server'34const supabase = createClient(5 process.env.SUPABASE_URL!,6 process.env.SUPABASE_SERVICE_ROLE_KEY!7)89export async function POST(req: NextRequest) {10 const { channel_id, sender_id, content, attachments } = await req.json()1112 if (!channel_id || !sender_id || !content?.trim()) {13 return NextResponse.json({ error: 'Missing fields' }, { status: 400 })14 }1516 const { data: member } = await supabase17 .from('channel_members')18 .select('user_id')19 .eq('channel_id', channel_id)20 .eq('user_id', sender_id)21 .single()2223 if (!member) {24 return NextResponse.json({ error: 'Not a member' }, { status: 403 })25 }2627 const { data, error } = await supabase28 .from('messages')29 .insert({ channel_id, sender_id, content, attachments: attachments || [] })30 .select()31 .single()3233 if (error) {34 return NextResponse.json({ error: error.message }, { status: 500 })35 }3637 return NextResponse.json({ data })38}Pro tip: Use cursor-based pagination: fetch messages WHERE created_at < lastMessageTimestamp ORDER BY created_at DESC LIMIT 50. This is much faster than offset pagination for large message histories.
Expected result: Messages send with optimistic UI (appear instantly before server confirms). Scrolling up loads older messages with cursor-based pagination.
Add channel management and deploy
Build channel creation, member management, and read receipt tracking. Then deploy the messaging platform.
1// Paste this prompt into V0's AI chat:2// Add channel management features:3// 1. Server Action for creating channels: insert into channels + add creator as admin member4// 2. Server Action for joining public channels: insert into channel_members5// 3. Server Action for inviting users to private channels6// 4. Read receipt tracking: when a user views a channel, update channel_members.last_read_at to now()7// 5. Unread count query: count messages WHERE created_at > channel_members.last_read_at for each channel8// 6. Channel settings Dialog: edit name/description, manage members Table with role Badge and remove Button9// Also add emoji reactions: Popover with common emoji grid, clicking adds/removes reaction on a message.10// Use ContextMenu on messages for: Edit, Delete, React, Reply (thread).Expected result: Channel management is complete with creation, joining, member management, and read receipts. The app is deployed to Vercel.
Complete code
1import { createClient } from '@supabase/supabase-js'2import { NextRequest, NextResponse } from 'next/server'34const supabase = createClient(5 process.env.SUPABASE_URL!,6 process.env.SUPABASE_SERVICE_ROLE_KEY!7)89export async function POST(req: NextRequest) {10 const { channel_id, sender_id, content, attachments } = await req.json()1112 if (!channel_id || !sender_id || !content?.trim()) {13 return NextResponse.json({ error: 'Missing fields' }, { status: 400 })14 }1516 const { data: member } = await supabase17 .from('channel_members')18 .select('user_id')19 .eq('channel_id', channel_id)20 .eq('user_id', sender_id)21 .single()2223 if (!member) {24 return NextResponse.json({ error: 'Not a channel member' }, { status: 403 })25 }2627 const { data, error } = await supabase28 .from('messages')29 .insert({30 channel_id,31 sender_id,32 content: content.trim(),33 attachments: attachments || [],34 })35 .select('*, sender:auth.users!sender_id(raw_user_meta_data)')36 .single()3738 if (error) {39 return NextResponse.json({ error: error.message }, { status: 500 })40 }4142 return NextResponse.json({ data })43}4445export async function GET(req: NextRequest) {46 const { searchParams } = new URL(req.url)47 const channelId = searchParams.get('channel_id')48 const cursor = searchParams.get('cursor')49 const limit = 505051 let query = supabase52 .from('messages')53 .select('*')54 .eq('channel_id', channelId!)55 .order('created_at', { ascending: false })56 .limit(limit)5758 if (cursor) {59 query = query.lt('created_at', cursor)60 }6162 const { data, error } = await query63 if (error) {64 return NextResponse.json({ error: error.message }, { status: 500 })65 }6667 return NextResponse.json({ data: data?.reverse(), has_more: data?.length === limit })68}Customization ideas
Thread replies
Add threaded conversations by using the parent_id field on messages. Show thread count on parent messages and open threads in a side panel.
Message search
Add full-text search across all messages using Supabase's tsvector full-text search for finding conversations by keyword.
Push notifications
Integrate web push notifications using the Web Push API and service workers to alert users of new messages when the tab is not active.
Voice messages
Add voice recording using the MediaRecorder API, upload audio to Supabase Storage, and display an inline audio player in the message.
Common pitfalls
Pitfall: Using offset pagination for message history
How to avoid: Use cursor-based pagination: fetch WHERE created_at < lastMessageTimestamp ORDER BY created_at DESC LIMIT 50. This is consistently fast regardless of message count.
Pitfall: Not unsubscribing from Realtime channels on component unmount
How to avoid: Return a cleanup function from useEffect that calls supabase.removeChannel(channel). This ensures subscriptions are properly cleaned up.
Pitfall: Persisting typing indicators to the database
How to avoid: Use Supabase Broadcast for typing indicators. Broadcast events are ephemeral WebSocket messages that are never stored in the database.
Best practices
- Use cursor-based pagination (created_at < cursor) instead of offset pagination for message history to maintain performance
- Clean up Realtime subscriptions in useEffect cleanup to prevent memory leaks when switching channels
- Use Supabase Broadcast for ephemeral events (typing, presence) and postgres_changes for persistent events (new messages)
- Implement optimistic UI for sending messages — add the message to local state before the server confirms
- Use V0's Design Mode (Option+D) to adjust message bubble styling, sidebar width, and avatar sizes without spending credits
- Verify Realtime is enabled on the messages table in Supabase Dashboard under Database > Replication
- Verify channel membership in the API route before allowing message sends to prevent unauthorized access
- Track last_read_at per channel member for accurate unread count calculations
AI prompts to try
Copy these prompts to build this project faster.
I'm building a real-time messaging platform with Next.js App Router and Supabase Realtime. I need help with the message subscription setup. When a user opens a channel, I need to subscribe to new messages (INSERT events on the messages table filtered by channel_id) and also set up Broadcast for typing indicators. Please write the useEffect hook that creates both subscriptions and cleans them up on unmount.
Create a real-time typing indicator system using Supabase Broadcast. When a user types in the message input, debounce and send a broadcast event with their user_id and name. Other users in the channel listen for these events and display 'X is typing...' with a 3-second timeout. The typing indicator should disappear when the user stops typing or sends the message. Use Supabase channel with broadcast event type.
Frequently asked questions
How does real-time messaging work without polling?
Supabase Realtime uses WebSocket connections. When a new message is inserted, Supabase detects the change via PostgreSQL's WAL (Write-Ahead Log) and pushes it to all connected clients subscribed to that channel. No polling needed.
What is the difference between postgres_changes and Broadcast?
postgres_changes listens for actual database changes (INSERT, UPDATE, DELETE) and delivers them to subscribers. Broadcast sends ephemeral WebSocket messages that are never stored. Use postgres_changes for messages and Broadcast for typing indicators and presence.
Do I need a paid Supabase plan for Realtime?
The free tier supports Realtime but with connection limits. For a production messaging app with multiple concurrent users, the Pro plan ($25/month) is recommended for higher connection limits and guaranteed performance.
Do I need a paid V0 plan?
Yes, Premium ($20/month) at minimum. The messaging platform has many complex components (chat layout, real-time subscriptions, typing indicators, file uploads) that require numerous prompts.
How do I handle message history efficiently?
Use cursor-based pagination. Fetch messages WHERE created_at < lastMessageTimestamp ORDER BY created_at DESC LIMIT 50. This is consistently fast regardless of how many messages exist, unlike offset pagination which slows down as the offset grows.
How do I deploy the messaging platform?
Click Share in V0, then Publish to Production. Set NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY in the Vars tab. Verify Realtime is enabled on the messages table in Supabase Dashboard.
Can RapidDev help build a custom messaging platform?
Yes. RapidDev has built over 600 apps including real-time messaging systems with channels, threading, file sharing, and push notifications. Book a free consultation to discuss your messaging requirements.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation