# How to Build Team workspace with V0

- Tool: v0
- Difficulty: Intermediate
- Compatibility: V0 Free
- Last updated: April 2026

## TL;DR

Build a Notion/Slack-style team workspace with V0 featuring organizations, channels with real-time chat, wiki pages, member management, and invite links. You'll create multi-tenant architecture with RLS, Supabase Realtime for live messaging, and Clerk authentication — all in about 1-2 hours.

## Before you start

- A V0 account (free tier works for this project)
- A Supabase project (free tier works — connect via V0's Connect panel)
- A Clerk account (free tier includes 10,000 monthly active users)
- Basic understanding of workspaces and channels (like Slack or Discord)

## Step-by-step guide

### 1. Set up the multi-tenant database schema

Open V0 and create a new project. Use the Connect panel to add Supabase. Create the organizations, org_members, channels, messages, and pages tables with multi-tenant RLS policies.

```
// Paste this prompt into V0's AI chat:
// Create a Supabase schema for a team workspace:
// 1. organizations table: id (uuid PK), name (text), slug (text UNIQUE), logo_url (text), created_at (timestamptz)
// 2. org_members table: org_id (uuid FK), user_id (uuid FK), role (text — 'owner', 'admin', 'member'), joined_at (timestamptz), PRIMARY KEY(org_id, user_id)
// 3. channels table: id (uuid PK), org_id (uuid FK), name (text), description (text), is_private (boolean DEFAULT false), created_at (timestamptz)
// 4. messages table: id (uuid PK), channel_id (uuid FK), author_id (uuid FK), content (text), edited_at (timestamptz nullable), created_at (timestamptz)
// 5. pages table: id (uuid PK), org_id (uuid FK), title (text), content (text — Markdown), author_id (uuid FK), parent_page_id (uuid FK nullable for nesting), updated_at (timestamptz)
// RLS: all tables scoped through org_members — users must be a member to access any org data.
// Enable Realtime on messages table.
// Seed a sample org with 3 channels and 5 pages.
```

> Pro tip: Store NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY and CLERK_SECRET_KEY in V0's Vars tab. The publishable key gets the NEXT_PUBLIC_ prefix for client-side auth, the secret key has no prefix for server-only use.

**Expected result:** Five tables created with multi-tenant RLS policies, Realtime enabled on messages, and sample workspace data seeded.

### 2. Build the workspace layout with sidebar navigation

Create the workspace layout with a sidebar showing channels and pages as a tree. The layout uses dynamic [org] routing for multi-tenant URL structure.

```
import { createClient } from '@/lib/supabase/server'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import Link from 'next/link'
import { notFound } from 'next/navigation'

export default async function WorkspaceLayout({
  children,
  params,
}: {
  children: React.ReactNode
  params: Promise<{ org: string }>
}) {
  const { org } = await params
  const supabase = await createClient()

  const { data: organization } = await supabase
    .from('organizations')
    .select('*')
    .eq('slug', org)
    .single()

  if (!organization) notFound()

  const { data: channels } = await supabase
    .from('channels')
    .select('id, name')
    .eq('org_id', organization.id)
    .order('name')

  const { data: pages } = await supabase
    .from('pages')
    .select('id, title, parent_page_id')
    .eq('org_id', organization.id)
    .is('parent_page_id', null)

  return (
    <div className="flex h-screen">
      <aside className="w-64 border-r bg-muted/30">
        <div className="p-4">
          <h2 className="font-bold text-lg">{organization.name}</h2>
        </div>
        <Separator />
        <ScrollArea className="flex-1 p-2">
          <p className="text-xs font-semibold text-muted-foreground px-2 mb-1">Channels</p>
          {channels?.map((ch) => (
            <Link key={ch.id} href={`/${org}/channels/${ch.id}`}>
              <Button variant="ghost" size="sm" className="w-full justify-start">
                # {ch.name}
              </Button>
            </Link>
          ))}
          <Separator className="my-2" />
          <p className="text-xs font-semibold text-muted-foreground px-2 mb-1">Pages</p>
          {pages?.map((page) => (
            <Link key={page.id} href={`/${org}/pages/${page.id}`}>
              <Button variant="ghost" size="sm" className="w-full justify-start">
                {page.title}
              </Button>
            </Link>
          ))}
        </ScrollArea>
      </aside>
      <main className="flex-1 overflow-auto">{children}</main>
    </div>
  )
}
```

**Expected result:** A workspace layout with a sidebar showing channels (prefixed with #) and pages as a tree. The main content area renders channel or page content.

### 3. Create the real-time channel messaging view

Build a client component for channel messaging that subscribes to Supabase Realtime for instant message delivery. Messages display in Card components with Avatar and timestamp.

```
'use client'

import { useEffect, useState } from 'react'
import { createClient } from '@/lib/supabase/client'
import { Card, CardContent } from '@/components/ui/card'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { Textarea } from '@/components/ui/textarea'
import { Button } from '@/components/ui/button'
import { sendMessage } from '@/app/actions/workspace'

type Message = {
  id: string
  content: string
  author_id: string
  created_at: string
}

export function ChannelChat({ channelId, initialMessages }: {
  channelId: string
  initialMessages: Message[]
}) {
  const [messages, setMessages] = useState(initialMessages)
  const supabase = createClient()

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

  return (
    <div className="flex flex-col h-full">
      <div className="flex-1 overflow-y-auto p-4 space-y-3">
        {messages.map((msg) => (
          <div key={msg.id} className="flex gap-3">
            <Avatar className="w-8 h-8">
              <AvatarFallback>U</AvatarFallback>
            </Avatar>
            <div>
              <p className="text-sm">{msg.content}</p>
              <p className="text-xs text-muted-foreground">
                {new Date(msg.created_at).toLocaleTimeString()}
              </p>
            </div>
          </div>
        ))}
      </div>
      <form action={sendMessage} className="p-4 border-t flex gap-2">
        <input type="hidden" name="channelId" value={channelId} />
        <Textarea name="content" placeholder="Type a message..." rows={1} className="flex-1" />
        <Button type="submit">Send</Button>
      </form>
    </div>
  )
}
```

**Expected result:** Real-time channel chat where messages appear instantly for all connected users. Each message shows an Avatar, content, and timestamp.

### 4. Build member management with invite links

Create an organization settings page with a member Table showing roles, a Select for changing roles, and a Dialog for generating invite links.

```
// Paste this prompt into V0's AI chat:
// Build an organization settings page at app/[org]/settings/page.tsx with:
// 1. Member management Table showing: Avatar, display name, email, role Select (owner/admin/member), joined date, Remove Button
// 2. "Invite Member" Button that opens a Dialog:
//    - Generate a unique invite link using crypto.randomUUID()
//    - Input field showing the link with a Copy button
//    - Optional: Input for email to send invite directly
// 3. Server Actions for: change role, remove member, create invite, accept invite
// 4. AlertDialog for confirming member removal
// 5. Organization details section: Input for org name, org logo upload area
// 6. Use Tabs for Members and Settings sections
// RLS ensures only admin+ roles can modify members.
```

> Pro tip: Multi-tenant RLS is the biggest challenge — every query must be scoped to the current org. Create a Supabase RPC function that validates org membership before returning data.

**Expected result:** A settings page with member Table, role management Select, invite link Dialog, and organization details editor.

### 5. Add wiki pages with nested hierarchy

Create the wiki page view and editor with Markdown content, nested page hierarchy using parent_page_id, and Breadcrumb navigation showing the page path.

```
// Paste this prompt into V0's AI chat:
// Build a wiki page system for the team workspace:
// 1. Page view at app/[org]/pages/[id]/page.tsx:
//    - Server Component fetching the page from Supabase
//    - Breadcrumb navigation showing the page hierarchy (root > parent > current)
//    - Markdown content rendered in prose styling
//    - Edit Button, New Child Page Button
//    - Sidebar showing child pages as links
// 2. Page editor at app/[org]/pages/[id]/edit/page.tsx:
//    - Input for title
//    - Textarea for Markdown content (full-width)
//    - Select for parent page (to reorganize hierarchy)
//    - Save Button calling a Server Action
// 3. Server Actions for: create page, update page, delete page, move page (change parent)
// Use Breadcrumb, Input, Textarea, Select, Button from shadcn/ui.
```

**Expected result:** Wiki pages with Breadcrumb navigation, Markdown content, child page listing, and an editor for creating and modifying pages.

## Complete code example

File: `app/[org]/layout.tsx`

```typescript
import { createClient } from '@/lib/supabase/server'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import Link from 'next/link'
import { notFound } from 'next/navigation'

export default async function WorkspaceLayout({
  children,
  params,
}: {
  children: React.ReactNode
  params: Promise<{ org: string }>
}) {
  const { org } = await params
  const supabase = await createClient()

  const { data: organization } = await supabase
    .from('organizations')
    .select('*')
    .eq('slug', org)
    .single()

  if (!organization) notFound()

  const { data: channels } = await supabase
    .from('channels')
    .select('id, name')
    .eq('org_id', organization.id)
    .order('name')

  const { data: pages } = await supabase
    .from('pages')
    .select('id, title')
    .eq('org_id', organization.id)
    .is('parent_page_id', null)

  return (
    <div className="flex h-screen">
      <aside className="w-64 border-r bg-muted/30 flex flex-col">
        <div className="p-4">
          <h2 className="font-bold">{organization.name}</h2>
        </div>
        <Separator />
        <ScrollArea className="flex-1 p-2">
          <p className="text-xs font-semibold text-muted-foreground px-2 mb-1">
            Channels
          </p>
          {channels?.map((ch) => (
            <Link key={ch.id} href={`/${org}/channels/${ch.id}`}>
              <Button variant="ghost" size="sm" className="w-full justify-start">
                # {ch.name}
              </Button>
            </Link>
          ))}
          <Separator className="my-2" />
          <p className="text-xs font-semibold text-muted-foreground px-2 mb-1">
            Pages
          </p>
          {pages?.map((page) => (
            <Link key={page.id} href={`/${org}/pages/${page.id}`}>
              <Button variant="ghost" size="sm" className="w-full justify-start">
                {page.title}
              </Button>
            </Link>
          ))}
        </ScrollArea>
      </aside>
      <main className="flex-1 overflow-auto">{children}</main>
    </div>
  )
}
```

## Common mistakes

- **Not scoping all queries to the current organization** — Without org scoping, users from one workspace could access channels and pages from another workspace by manipulating IDs in the URL. Fix: Every Supabase query must include .eq('org_id', currentOrgId) or use RLS policies that join through org_members to verify membership.
- **Not enabling Supabase Realtime on the messages table** — Chat messages will not appear in real-time. Users must manually refresh to see new messages, defeating the purpose of a messaging feature. Fix: Enable Realtime on the messages table in Supabase Dashboard > Database > Replication.
- **Using NEXT_PUBLIC_ prefix for CLERK_SECRET_KEY** — The Clerk secret key gives full access to your Clerk account. Exposing it in the browser allows anyone to create, delete, or modify user accounts. Fix: Store CLERK_SECRET_KEY in V0's Vars tab without NEXT_PUBLIC_ prefix. Only NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY should have the prefix.

## Best practices

- Use multi-tenant RLS policies on all tables — every query must be scoped to the current organization via org_members membership
- Store NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY (client) and CLERK_SECRET_KEY (server-only, no prefix) in V0's Vars tab
- Enable Supabase Realtime on the messages table for live chat without polling
- Use dynamic [org] routes for multi-tenant URL structure so each workspace has a clean URL like /acme/channels/general
- Use Server Components for wiki pages and member management — they load faster and don't need real-time updates
- Use Design Mode (Option+D) to visually adjust sidebar width, channel list spacing, and message bubble styling at zero credit cost
- Use Breadcrumb for wiki page hierarchy navigation so users always know where they are in the page tree

## Frequently asked questions

### How does multi-tenant isolation work?

Every table has an org_id column. RLS policies on all tables check that the current user is a member of the organization via the org_members table. Even if someone guesses a channel or page UUID, they cannot access it without being an org member.

### What V0 plan do I need?

V0 Free tier works. The workspace uses standard Server Components, client components for chat, and shadcn/ui. Supabase Realtime is included on the free tier. Clerk free tier includes 10,000 monthly active users.

### Can I use Supabase Auth instead of Clerk?

Yes. Replace Clerk with Supabase Auth by using createBrowserClient and createServerClient for auth. Clerk is recommended for fastest setup with pre-built UI components, but Supabase Auth works if you are already using Supabase.

### How do real-time messages work?

Supabase Realtime pushes new message INSERT events via WebSocket to all clients subscribed to that channel. The client component prepends new messages to the local state without any refresh.

### How do I deploy this workspace?

Click Share > Publish in V0. Add CLERK_SECRET_KEY in the Vercel Dashboard. The Supabase connection is auto-configured from the Connect panel. Make sure Realtime is enabled on the messages table.

### Can RapidDev help build a custom team workspace?

Yes. RapidDev has built 600+ apps including team collaboration platforms with real-time messaging, document collaboration, and custom integrations. Book a free consultation to discuss your workspace requirements.

### Can I add file sharing to channels?

Yes. Use Supabase Storage to create a bucket for workspace files. Add a file upload Input to the message composer. Upload the file, get the public URL, and include it in the message content.

---

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