Skip to main content
RapidDev - Software Development Agency

How to Build Messaging platform with V0

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'll build

  • Channel-based messaging with public, private, and DM channel types
  • Real-time message delivery using Supabase Realtime postgres_changes subscriptions
  • Typing indicators and online presence using Supabase Broadcast ephemeral events
  • Cursor-based infinite scroll pagination for efficient message history loading
  • File attachment uploads to Supabase Storage with inline preview
  • Unread message counts with Badge indicators and last_read_at tracking
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Advanced11 min read2-4 hoursV0 Premium or higherApril 2026RapidDev Engineering Team
TL;DR

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

V0AI Code Generator
Next.jsFull-Stack Framework
Tailwind CSSStyling
shadcn/uiComponent Library
SupabaseDatabase
Supabase RealtimeRealtime

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

1

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.

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

2

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.

prompt.txt
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 count
6// - Channel list grouped by type: Channels (public/private), Direct Messages
7// - Active channel highlighted with accent background
8// - 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 bottom
10// - Message composer: Input with send Button, attachment upload Button, emoji Popover
11// - Use shadcn/ui ScrollArea for messages, Badge for unread counts, Avatar for user icons, ContextMenu for message actions
12// - Mobile: sidebar collapses into a Sheet triggered by hamburger menu

Expected result: The chat interface shows a channel sidebar with unread counts and a message area with a composer input.

3

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.

components/message-list.tsx
1'use client'
2
3import { useEffect, useState, useRef } from 'react'
4import { createBrowserClient } from '@supabase/ssr'
5import { ScrollArea } from '@/components/ui/scroll-area'
6import { Avatar, AvatarFallback } from '@/components/ui/avatar'
7
8interface Message {
9 id: string
10 content: string
11 sender_id: string
12 sender_name: string
13 created_at: string
14 attachments: string[]
15}
16
17export function MessageList({ channelId, initialMessages }: {
18 channelId: string
19 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 )
27
28 useEffect(() => {
29 const channel = supabase
30 .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()
41
42 return () => { supabase.removeChannel(channel) }
43 }, [channelId, supabase])
44
45 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.

4

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.

components/typing-indicator.tsx
1'use client'
2
3import { useEffect, useState } from 'react'
4import { createBrowserClient } from '@supabase/ssr'
5import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
6
7export function TypingIndicator({ channelId, userId }: {
8 channelId: string
9 userId: string
10}) {
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 )
16
17 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()
30
31 return () => { supabase.removeChannel(channel) }
32 }, [channelId, userId, supabase])
33
34 if (typingUsers.length === 0) return null
35
36 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}
47
48export function sendTypingEvent(
49 supabase: ReturnType<typeof createBrowserClient>,
50 channelId: string,
51 userId: string,
52 userName: string
53) {
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.

5

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.

app/api/messages/route.ts
1import { createClient } from '@supabase/supabase-js'
2import { NextRequest, NextResponse } from 'next/server'
3
4const supabase = createClient(
5 process.env.SUPABASE_URL!,
6 process.env.SUPABASE_SERVICE_ROLE_KEY!
7)
8
9export async function POST(req: NextRequest) {
10 const { channel_id, sender_id, content, attachments } = await req.json()
11
12 if (!channel_id || !sender_id || !content?.trim()) {
13 return NextResponse.json({ error: 'Missing fields' }, { status: 400 })
14 }
15
16 const { data: member } = await supabase
17 .from('channel_members')
18 .select('user_id')
19 .eq('channel_id', channel_id)
20 .eq('user_id', sender_id)
21 .single()
22
23 if (!member) {
24 return NextResponse.json({ error: 'Not a member' }, { status: 403 })
25 }
26
27 const { data, error } = await supabase
28 .from('messages')
29 .insert({ channel_id, sender_id, content, attachments: attachments || [] })
30 .select()
31 .single()
32
33 if (error) {
34 return NextResponse.json({ error: error.message }, { status: 500 })
35 }
36
37 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.

6

Add channel management and deploy

Build channel creation, member management, and read receipt tracking. Then deploy the messaging platform.

prompt.txt
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 member
4// 2. Server Action for joining public channels: insert into channel_members
5// 3. Server Action for inviting users to private channels
6// 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 channel
8// 6. Channel settings Dialog: edit name/description, manage members Table with role Badge and remove Button
9// 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

app/api/messages/route.ts
1import { createClient } from '@supabase/supabase-js'
2import { NextRequest, NextResponse } from 'next/server'
3
4const supabase = createClient(
5 process.env.SUPABASE_URL!,
6 process.env.SUPABASE_SERVICE_ROLE_KEY!
7)
8
9export async function POST(req: NextRequest) {
10 const { channel_id, sender_id, content, attachments } = await req.json()
11
12 if (!channel_id || !sender_id || !content?.trim()) {
13 return NextResponse.json({ error: 'Missing fields' }, { status: 400 })
14 }
15
16 const { data: member } = await supabase
17 .from('channel_members')
18 .select('user_id')
19 .eq('channel_id', channel_id)
20 .eq('user_id', sender_id)
21 .single()
22
23 if (!member) {
24 return NextResponse.json({ error: 'Not a channel member' }, { status: 403 })
25 }
26
27 const { data, error } = await supabase
28 .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()
37
38 if (error) {
39 return NextResponse.json({ error: error.message }, { status: 500 })
40 }
41
42 return NextResponse.json({ data })
43}
44
45export 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 = 50
50
51 let query = supabase
52 .from('messages')
53 .select('*')
54 .eq('channel_id', channelId!)
55 .order('created_at', { ascending: false })
56 .limit(limit)
57
58 if (cursor) {
59 query = query.lt('created_at', cursor)
60 }
61
62 const { data, error } = await query
63 if (error) {
64 return NextResponse.json({ error: error.message }, { status: 500 })
65 }
66
67 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.

ChatGPT Prompt

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.

Build Prompt

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.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help building your app?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.