Build a real-time chat application with V0 using Next.js and Supabase Realtime. You'll get group channels, private direct messages, typing indicators, online presence tracking, read receipts, and cursor-based message pagination — all in about 1-2 hours without any local setup.
What you're building
Real-time messaging is a core feature for communities, team tools, and social platforms. Users expect instant message delivery, typing indicators, and online presence — the same experience they get from Slack or Discord.
V0 generates the chat interface, channel management, and message components from prompts. Supabase Realtime via the Connect panel provides WebSocket-based message delivery with under 200ms latency, plus Presence channels for typing indicators and online status.
The architecture uses a Next.js App Router layout with a sidebar for channels and a main area for messages. Server Components fetch initial channel and message data. A Client Component subscribes to Supabase Realtime for live message inserts and Presence for typing/online indicators. Messages are paginated with cursor-based loading for performance. Server Actions handle channel creation and member management.
Final result
A complete real-time chat application with group channels, direct messages, typing indicators, online presence, unread badges, message pagination, and channel management.
Tech stack
Prerequisites
- A V0 account (Premium plan recommended for real-time features)
- A Supabase project with Realtime enabled (free tier works — connect via V0's Connect panel)
- Understanding of channel-based messaging concepts (groups, direct messages)
Build steps
Set up the database schema with channels and messages
Create a new V0 project, connect Supabase, and create the channels, members, messages, and presence tables. Enable Realtime on the messages table for instant delivery.
1// Paste this prompt into V0's AI chat:2// Build a real-time chat app with Supabase. Create these tables:3// 1. channels: id (uuid PK), name (text), type (text CHECK in 'group','direct'), created_by (uuid FK to auth.users), created_at (timestamptz)4// 2. channel_members: channel_id (uuid FK), user_id (uuid FK), 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), sender_id (uuid FK to auth.users), content (text), type (text default 'text'), metadata (jsonb), created_at (timestamptz), updated_at (timestamptz)6// Enable Realtime on the messages table.7// Add RLS: only channel members can read/write messages in their channels.Pro tip: Enable Realtime on the messages table in Supabase Dashboard under Database then Replication. Without this, the client subscription will connect but never receive message events.
Expected result: Supabase is connected with chat tables created, Realtime enabled on messages, and RLS policies restricting access to channel members.
Build the chat layout with channel sidebar and message area
Create the main chat layout with a sidebar showing channels with unread counts and a main area for the active channel's messages. The layout uses Server Components for initial data and a Client Component wrapper for real-time updates.
1// Paste this prompt into V0's AI chat:2// Build a chat layout at app/chat/layout.tsx.3// Requirements:4// - Left sidebar showing channel list with:5// - Channel name and type icon (group/direct)6// - Unread message count Badge (compare last_read_at with latest message created_at)7// - Active channel highlighted8// - "New Channel" Button at top9// - Main area on the right for message thread (rendered by child pages)10// - Use shadcn/ui Sidebar for the channel list, Badge for unread counts, Avatar for user icons in DMs11// - Server Component fetches channels for the current user from channel_members join channels12// - Responsive: sidebar collapses to Sheet on mobileExpected result: The chat layout shows channels in a sidebar with unread Badges. Clicking a channel loads the message thread in the main area.
Create the real-time message thread with Supabase Realtime
Build the message thread component that loads initial messages from Supabase and subscribes to real-time inserts. New messages appear instantly for all channel members. Include auto-scroll to bottom and date group separators.
1import { NextRequest, NextResponse } from 'next/server'2import { createClient } from '@supabase/supabase-js'34const supabase = createClient(5 process.env.SUPABASE_URL!,6 process.env.SUPABASE_SERVICE_ROLE_KEY!7)89export async function POST(req: NextRequest) {10 const { channelId, senderId, content } = await req.json()1112 const { data: member } = await supabase13 .from('channel_members')14 .select('user_id')15 .eq('channel_id', channelId)16 .eq('user_id', senderId)17 .single()1819 if (!member) {20 return NextResponse.json({ error: 'Not a channel member' }, { status: 403 })21 }2223 const { data: message, error } = await supabase24 .from('messages')25 .insert({26 channel_id: channelId,27 sender_id: senderId,28 content,29 })30 .select()31 .single()3233 if (error) return NextResponse.json({ error: error.message }, { status: 500 })3435 await supabase36 .from('channel_members')37 .update({ last_read_at: new Date().toISOString() })38 .eq('channel_id', channelId)39 .eq('user_id', senderId)4041 return NextResponse.json({ message })42}Expected result: Sending a message inserts it into Supabase and updates the sender's last_read_at. The Realtime subscription delivers the message to all channel members instantly.
Add typing indicators and online presence
Implement typing indicators and online status using Supabase Realtime Presence. When a user starts typing, they broadcast their presence to the channel. Other members see who is typing in real-time.
1// Paste this prompt into V0's AI chat:2// Build a real-time typing indicator and presence component for the chat.3// Requirements:4// - 'use client' component that wraps the message thread5// - Subscribe to Supabase Realtime channel `chat:${channelId}`6// - Listen for INSERT events on the messages table filtered by channel_id for new messages7// - Use Presence to track online users and typing status:8// - On message input focus/typing, call channel.track({ user_id, typing: true })9// - On blur or 3-second debounce, call channel.track({ user_id, typing: false })10// - Read presenceState() to show which users are typing and who is online11// - Display typing indicator text: "Alice is typing..." or "Alice and Bob are typing..."12// - Show green dot indicator next to online users in the channel member list13// - Merge incoming messages with the loaded list without duplicates (check message id)14// - Auto-scroll to bottom on new messages unless the user has scrolled upPro tip: Subscribe to both Realtime table changes (for messages) and Presence (for typing/online) on the same Supabase channel. Use channel.on('postgres_changes', ...) for messages and channel.on('presence', ...) for typing state.
Expected result: Users see typing indicators when others type. Online presence dots show who is currently active in the channel. New messages appear instantly.
Add message pagination and channel management
Implement cursor-based pagination for loading older messages on scroll-up, and add channel creation with member invitations. Use an IntersectionObserver to detect when the user scrolls to the top of the message list.
1'use server'23import { createClient } from '@supabase/supabase-js'4import { revalidatePath } from 'next/cache'56const supabase = createClient(7 process.env.SUPABASE_URL!,8 process.env.SUPABASE_SERVICE_ROLE_KEY!9)1011export async function createChannel(12 name: string,13 type: 'group' | 'direct',14 createdBy: string,15 memberIds: string[]16) {17 const { data: channel, error } = await supabase18 .from('channels')19 .insert({ name, type, created_by: createdBy })20 .select()21 .single()2223 if (error) throw new Error(error.message)2425 const members = [createdBy, ...memberIds].map((userId) => ({26 channel_id: channel.id,27 user_id: userId,28 role: userId === createdBy ? 'admin' : 'member',29 }))3031 await supabase.from('channel_members').insert(members)32 revalidatePath('/chat')33 return channel34}3536export async function loadOlderMessages(37 channelId: string,38 beforeTimestamp: string,39 limit: number = 5040) {41 const { data } = await supabase42 .from('messages')43 .select('*')44 .eq('channel_id', channelId)45 .lt('created_at', beforeTimestamp)46 .order('created_at', { ascending: false })47 .limit(limit)4849 return data?.reverse() ?? []50}Expected result: Scrolling to the top of the message list loads older messages. Users can create new channels and invite members via a Dialog form.
Complete code
1import { NextRequest, NextResponse } from 'next/server'2import { createClient } from '@supabase/supabase-js'34const supabase = createClient(5 process.env.SUPABASE_URL!,6 process.env.SUPABASE_SERVICE_ROLE_KEY!7)89export async function POST(req: NextRequest) {10 const { channelId, senderId, content } = await req.json()1112 const { data: member } = await supabase13 .from('channel_members')14 .select('user_id')15 .eq('channel_id', channelId)16 .eq('user_id', senderId)17 .single()1819 if (!member) {20 return NextResponse.json(21 { error: 'Not a channel member' },22 { status: 403 }23 )24 }2526 const { data: message, error } = await supabase27 .from('messages')28 .insert({ channel_id: channelId, sender_id: senderId, content })29 .select('id, channel_id, sender_id, content, created_at')30 .single()3132 if (error) {33 return NextResponse.json({ error: error.message }, { status: 500 })34 }3536 await supabase37 .from('channel_members')38 .update({ last_read_at: new Date().toISOString() })39 .eq('channel_id', channelId)40 .eq('user_id', senderId)4142 return NextResponse.json({ message })43}4445export async function GET(req: NextRequest) {46 const { searchParams } = new URL(req.url)47 const channelId = searchParams.get('channel_id')!48 const before = searchParams.get('before')49 const limit = parseInt(searchParams.get('limit') ?? '50')5051 let query = supabase52 .from('messages')53 .select('*')54 .eq('channel_id', channelId)55 .order('created_at', { ascending: false })56 .limit(limit)5758 if (before) query = query.lt('created_at', before)5960 const { data } = await query61 return NextResponse.json({ messages: data?.reverse() ?? [] })62}Customization ideas
Add file sharing
Upload files to Supabase Storage and send them as message attachments with preview thumbnails for images and download links for documents.
Add message reactions
Add an emoji reaction system where users can react to messages with emojis stored in a reactions table, displayed as small badges below messages.
Add message search
Implement full-text search across messages using Supabase's tsvector with a search Input in the sidebar that highlights matching messages in the thread.
Add thread replies
Add a replies feature where users can start a thread on any message, displayed in a Sheet slide-over with its own Realtime subscription.
Common pitfalls
Pitfall: Not enabling Realtime replication on the messages table
How to avoid: Go to Supabase Dashboard, navigate to Database then Replication, and enable Realtime for the messages table. Also enable it for channel_members if you want real-time unread count updates.
Pitfall: Appending duplicate messages from Realtime subscription
How to avoid: Check the message id before appending to the local list. If the id already exists (from optimistic insert), skip the duplicate. Use a Set or Map keyed by message id.
Pitfall: Loading all messages at once without pagination
How to avoid: Load only the latest 50 messages initially. Use cursor-based pagination (keyset on created_at) to load older messages when the user scrolls to the top. Use IntersectionObserver to detect scroll position.
Best practices
- Enable Realtime on the messages table in Supabase Dashboard under Database then Replication before subscribing
- Use cursor-based pagination (keyset on created_at) for message loading — never OFFSET/LIMIT for chat messages
- Deduplicate messages by checking id before appending from Realtime subscriptions to prevent sender duplicates
- Use Supabase Realtime Presence for typing indicators and online status — track() to broadcast, presenceState() to read
- Use Server Components for the channel layout and initial data fetch, Client Components only for the real-time message thread
- Use Design Mode (Option+D) to adjust message bubble styling, sidebar width, and avatar sizes without spending credits
- Update last_read_at when the user sends a message or scrolls to the bottom to accurately track unread counts
- Use RLS policies so only channel members can read and send messages in their channels
AI prompts to try
Copy these prompts to build this project faster.
I'm building a real-time chat app with Next.js App Router and Supabase Realtime. I need group channels, message pagination, typing indicators via Presence, and online status tracking. Help me design the Realtime subscription pattern that handles both message inserts and Presence events on the same channel.
Build a Supabase Realtime chat component that subscribes to INSERT events on the messages table filtered by channel_id, and uses Presence to track typing and online status. The component should: load initial 50 messages, subscribe to new inserts and merge without duplicates, track typing state with debounced channel.track(), display typing indicator text, and auto-scroll to new messages unless the user has scrolled up.
Frequently asked questions
How fast are messages delivered with Supabase Realtime?
Supabase Realtime delivers messages within 200ms on average. It uses WebSocket connections maintained by the client, so there is no polling delay. Messages appear nearly instantly for all connected channel members.
How many concurrent users can the free Supabase tier handle?
The free tier supports 200 concurrent Realtime connections. This is enough for development and small teams. For production chat apps with more concurrent users, upgrade to Supabase Pro.
What V0 plan do I need for a chat app?
V0 Premium is recommended because the chat app requires real-time features, multiple components (sidebar, message thread, composer, presence), and Supabase Realtime integration.
How do I handle message history for new channel members?
When a user joins a channel, they see all message history from the beginning. If you want to limit this, add a joined_at timestamp to channel_members and filter messages to only show those created after the member joined.
How do I deploy the chat app?
Click Share then Publish to Production in V0. Supabase Realtime works automatically with the production URL. No additional webhook registration or configuration is needed.
Can RapidDev help build a custom chat application?
Yes. RapidDev has built 600+ apps including real-time messaging platforms with end-to-end encryption, media sharing, and scalable WebSocket infrastructure. Book a free consultation to discuss your chat requirements.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation