# How to Build Chat application with V0

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

## TL;DR

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.

## Before you start

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

## Step-by-step guide

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

```
// Paste this prompt into V0's AI chat:
// Build a real-time chat app with Supabase. Create these tables:
// 1. channels: id (uuid PK), name (text), type (text CHECK in 'group','direct'), created_by (uuid FK to auth.users), created_at (timestamptz)
// 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)
// 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)
// Enable Realtime on the messages table.
// 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.

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

```
// Paste this prompt into V0's AI chat:
// Build a chat layout at app/chat/layout.tsx.
// Requirements:
// - Left sidebar showing channel list with:
//   - Channel name and type icon (group/direct)
//   - Unread message count Badge (compare last_read_at with latest message created_at)
//   - Active channel highlighted
//   - "New Channel" Button at top
// - Main area on the right for message thread (rendered by child pages)
// - Use shadcn/ui Sidebar for the channel list, Badge for unread counts, Avatar for user icons in DMs
// - Server Component fetches channels for the current user from channel_members join channels
// - Responsive: sidebar collapses to Sheet on mobile
```

**Expected result:** The chat layout shows channels in a sidebar with unread Badges. Clicking a channel loads the message thread in the main area.

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

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

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

export async function POST(req: NextRequest) {
  const { channelId, senderId, content } = await req.json()

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

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

  const { data: message, error } = await supabase
    .from('messages')
    .insert({
      channel_id: channelId,
      sender_id: senderId,
      content,
    })
    .select()
    .single()

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

  await supabase
    .from('channel_members')
    .update({ last_read_at: new Date().toISOString() })
    .eq('channel_id', channelId)
    .eq('user_id', senderId)

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

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

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

```
// Paste this prompt into V0's AI chat:
// Build a real-time typing indicator and presence component for the chat.
// Requirements:
// - 'use client' component that wraps the message thread
// - Subscribe to Supabase Realtime channel `chat:${channelId}`
// - Listen for INSERT events on the messages table filtered by channel_id for new messages
// - Use Presence to track online users and typing status:
//   - On message input focus/typing, call channel.track({ user_id, typing: true })
//   - On blur or 3-second debounce, call channel.track({ user_id, typing: false })
//   - Read presenceState() to show which users are typing and who is online
// - Display typing indicator text: "Alice is typing..." or "Alice and Bob are typing..."
// - Show green dot indicator next to online users in the channel member list
// - Merge incoming messages with the loaded list without duplicates (check message id)
// - Auto-scroll to bottom on new messages unless the user has scrolled up
```

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

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

```
'use server'

import { createClient } from '@supabase/supabase-js'
import { revalidatePath } from 'next/cache'

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

export async function createChannel(
  name: string,
  type: 'group' | 'direct',
  createdBy: string,
  memberIds: string[]
) {
  const { data: channel, error } = await supabase
    .from('channels')
    .insert({ name, type, created_by: createdBy })
    .select()
    .single()

  if (error) throw new Error(error.message)

  const members = [createdBy, ...memberIds].map((userId) => ({
    channel_id: channel.id,
    user_id: userId,
    role: userId === createdBy ? 'admin' : 'member',
  }))

  await supabase.from('channel_members').insert(members)
  revalidatePath('/chat')
  return channel
}

export async function loadOlderMessages(
  channelId: string,
  beforeTimestamp: string,
  limit: number = 50
) {
  const { data } = await supabase
    .from('messages')
    .select('*')
    .eq('channel_id', channelId)
    .lt('created_at', beforeTimestamp)
    .order('created_at', { ascending: false })
    .limit(limit)

  return data?.reverse() ?? []
}
```

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

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

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

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

export async function POST(req: NextRequest) {
  const { channelId, senderId, content } = await req.json()

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

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

  const { data: message, error } = await supabase
    .from('messages')
    .insert({ channel_id: channelId, sender_id: senderId, content })
    .select('id, channel_id, sender_id, content, created_at')
    .single()

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

  await supabase
    .from('channel_members')
    .update({ last_read_at: new Date().toISOString() })
    .eq('channel_id', channelId)
    .eq('user_id', senderId)

  return NextResponse.json({ message })
}

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

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

  if (before) query = query.lt('created_at', before)

  const { data } = await query
  return NextResponse.json({ messages: data?.reverse() ?? [] })
}
```

## Common mistakes

- **Not enabling Realtime replication on the messages table** — Supabase Realtime is not enabled on tables by default. Without enabling it, the client subscription connects but never receives any message events. Fix: 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.
- **Appending duplicate messages from Realtime subscription** — The sender receives their own message back via Realtime subscription, so it appears twice — once from the optimistic local insert and once from the subscription. Fix: 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.
- **Loading all messages at once without pagination** — Channels with thousands of messages will cause extremely slow page loads and high memory usage on the client. Fix: 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

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

---

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