# How to Build Messaging platform with V0

- Tool: v0
- Difficulty: Advanced
- Compatibility: V0 Premium or higher
- Last updated: April 2026

## 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.

## Before you start

- 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

## Step-by-step guide

### 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.

```
// Paste this prompt into V0's AI chat:
// Build a messaging platform. Create a Supabase schema:
// 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)
// 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)
// 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)
// 4. reactions: id (uuid PK), message_id (uuid FK to messages), user_id (uuid FK to auth.users), emoji (text)
// 5. user_presence: user_id (uuid PK FK to auth.users), status (text DEFAULT 'offline'), last_seen (timestamptz)
// 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.

```
// Paste this prompt into V0's AI chat:
// Create a messaging layout at app/chat/page.tsx.
// Requirements:
// - Two-column layout: sidebar (25%) + main message area (75%)
// - Sidebar: list of channels with channel name, type icon (hash for public, lock for private, user for DM), unread Badge count
// - Channel list grouped by type: Channels (public/private), Direct Messages
// - Active channel highlighted with accent background
// - Add a 'New Channel' Button that opens a Dialog (name Input, description Textarea, type RadioGroup)
// - Main area: channel name header, message ScrollArea, input composer at bottom
// - Message composer: Input with send Button, attachment upload Button, emoji Popover
// - Use shadcn/ui ScrollArea for messages, Badge for unread counts, Avatar for user icons, ContextMenu for message actions
// - 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.

```
'use client'

import { useEffect, useState, useRef } from 'react'
import { createBrowserClient } from '@supabase/ssr'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'

interface Message {
  id: string
  content: string
  sender_id: string
  sender_name: string
  created_at: string
  attachments: string[]
}

export function MessageList({ channelId, initialMessages }: {
  channelId: string
  initialMessages: Message[]
}) {
  const [messages, setMessages] = useState(initialMessages)
  const bottomRef = useRef<HTMLDivElement>(null)
  const supabase = createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
  )

  useEffect(() => {
    const channel = supabase
      .channel(`messages:${channelId}`)
      .on('postgres_changes', {
        event: 'INSERT',
        schema: 'public',
        table: 'messages',
        filter: `channel_id=eq.${channelId}`,
      }, (payload) => {
        setMessages((prev) => [...prev, payload.new as Message])
        bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
      })
      .subscribe()

    return () => { supabase.removeChannel(channel) }
  }, [channelId, supabase])

  return (
    <ScrollArea className="flex-1 p-4">
      {messages.map((msg) => (
        <div key={msg.id} className="flex gap-3 mb-4">
          <Avatar><AvatarFallback>{msg.sender_name[0]}</AvatarFallback></Avatar>
          <div>
            <p className="text-sm font-medium">{msg.sender_name}</p>
            <p className="text-sm">{msg.content}</p>
          </div>
        </div>
      ))}
      <div ref={bottomRef} />
    </ScrollArea>
  )
}
```

**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.

```
'use client'

import { useEffect, useState } from 'react'
import { createBrowserClient } from '@supabase/ssr'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'

export function TypingIndicator({ channelId, userId }: {
  channelId: string
  userId: string
}) {
  const [typingUsers, setTypingUsers] = useState<string[]>([])
  const supabase = createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
  )

  useEffect(() => {
    const channel = supabase.channel(`typing:${channelId}`)
      .on('broadcast', { event: 'typing' }, ({ payload }) => {
        if (payload.user_id !== userId) {
          setTypingUsers((prev) =>
            prev.includes(payload.user_name) ? prev : [...prev, payload.user_name]
          )
          setTimeout(() => {
            setTypingUsers((prev) => prev.filter((u) => u !== payload.user_name))
          }, 3000)
        }
      })
      .subscribe()

    return () => { supabase.removeChannel(channel) }
  }, [channelId, userId, supabase])

  if (typingUsers.length === 0) return null

  return (
    <Tooltip>
      <TooltipTrigger asChild>
        <p className="text-xs text-muted-foreground animate-pulse">
          {typingUsers.join(', ')} {typingUsers.length === 1 ? 'is' : 'are'} typing...
        </p>
      </TooltipTrigger>
      <TooltipContent>Active users in this channel</TooltipContent>
    </Tooltip>
  )
}

export function sendTypingEvent(
  supabase: ReturnType<typeof createBrowserClient>,
  channelId: string,
  userId: string,
  userName: string
) {
  supabase.channel(`typing:${channelId}`).send({
    type: 'broadcast',
    event: 'typing',
    payload: { user_id: userId, user_name: userName },
  })
}
```

**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.

```
import { createClient } from '@supabase/supabase-js'
import { NextRequest, NextResponse } from 'next/server'

const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
)

export async function POST(req: NextRequest) {
  const { channel_id, sender_id, content, attachments } = await req.json()

  if (!channel_id || !sender_id || !content?.trim()) {
    return NextResponse.json({ error: 'Missing fields' }, { status: 400 })
  }

  const { data: member } = await supabase
    .from('channel_members')
    .select('user_id')
    .eq('channel_id', channel_id)
    .eq('user_id', sender_id)
    .single()

  if (!member) {
    return NextResponse.json({ error: 'Not a member' }, { status: 403 })
  }

  const { data, error } = await supabase
    .from('messages')
    .insert({ channel_id, sender_id, content, attachments: attachments || [] })
    .select()
    .single()

  if (error) {
    return NextResponse.json({ error: error.message }, { status: 500 })
  }

  return NextResponse.json({ data })
}
```

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

```
// Paste this prompt into V0's AI chat:
// Add channel management features:
// 1. Server Action for creating channels: insert into channels + add creator as admin member
// 2. Server Action for joining public channels: insert into channel_members
// 3. Server Action for inviting users to private channels
// 4. Read receipt tracking: when a user views a channel, update channel_members.last_read_at to now()
// 5. Unread count query: count messages WHERE created_at > channel_members.last_read_at for each channel
// 6. Channel settings Dialog: edit name/description, manage members Table with role Badge and remove Button
// Also add emoji reactions: Popover with common emoji grid, clicking adds/removes reaction on a message.
// 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 example

File: `app/api/messages/route.ts`

```typescript
import { createClient } from '@supabase/supabase-js'
import { NextRequest, NextResponse } from 'next/server'

const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
)

export async function POST(req: NextRequest) {
  const { channel_id, sender_id, content, attachments } = await req.json()

  if (!channel_id || !sender_id || !content?.trim()) {
    return NextResponse.json({ error: 'Missing fields' }, { status: 400 })
  }

  const { data: member } = await supabase
    .from('channel_members')
    .select('user_id')
    .eq('channel_id', channel_id)
    .eq('user_id', sender_id)
    .single()

  if (!member) {
    return NextResponse.json({ error: 'Not a channel member' }, { status: 403 })
  }

  const { data, error } = await supabase
    .from('messages')
    .insert({
      channel_id,
      sender_id,
      content: content.trim(),
      attachments: attachments || [],
    })
    .select('*, sender:auth.users!sender_id(raw_user_meta_data)')
    .single()

  if (error) {
    return NextResponse.json({ error: error.message }, { status: 500 })
  }

  return NextResponse.json({ data })
}

export async function GET(req: NextRequest) {
  const { searchParams } = new URL(req.url)
  const channelId = searchParams.get('channel_id')
  const cursor = searchParams.get('cursor')
  const limit = 50

  let query = supabase
    .from('messages')
    .select('*')
    .eq('channel_id', channelId!)
    .order('created_at', { ascending: false })
    .limit(limit)

  if (cursor) {
    query = query.lt('created_at', cursor)
  }

  const { data, error } = await query
  if (error) {
    return NextResponse.json({ error: error.message }, { status: 500 })
  }

  return NextResponse.json({ data: data?.reverse(), has_more: data?.length === limit })
}
```

## Common mistakes

- **Using offset pagination for message history** — Offset pagination becomes slower as the offset grows because the database must skip all previous rows. With thousands of messages, scrolling up becomes very slow. Fix: Use cursor-based pagination: fetch WHERE created_at < lastMessageTimestamp ORDER BY created_at DESC LIMIT 50. This is consistently fast regardless of message count.
- **Not unsubscribing from Realtime channels on component unmount** — Without cleanup, each channel navigation creates a new subscription while the old one keeps running, causing memory leaks and duplicate messages. Fix: Return a cleanup function from useEffect that calls supabase.removeChannel(channel). This ensures subscriptions are properly cleaned up.
- **Persisting typing indicators to the database** — Typing events are ephemeral and high-frequency. Storing them creates unnecessary database writes and requires cleanup logic. Fix: 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

## 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.

---

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