# How to Build CRM system with V0

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

## TL;DR

Build a pipeline-based CRM system with V0 using Next.js and Supabase. You'll get a Kanban deal board with intuitive drag-and-drop, contact management, activity timeline, weighted revenue forecasting, and global search — all in about 1-2 hours without any local setup.

## Before you start

- A V0 account (Premium plan recommended for multi-page builds)
- A Supabase project (free tier works — connect via V0's Connect panel)
- Understanding of your sales pipeline stages (e.g., Lead, Qualified, Proposal, Won, Lost)

## Step-by-step guide

### 1. Set up the CRM database schema with pipeline stages

Create a new V0 project, connect Supabase via the Connect panel, and create the contacts, pipelines, deals, and activities tables. The pipeline stages are stored as a JSONB column for easy customization.

```
// Paste this prompt into V0's AI chat:
// Build a CRM system with Supabase. Create these tables:
// 1. contacts: id (uuid PK), owner_id (uuid FK to auth.users), company (text), name (text), email (text), phone (text), source (text), tags (text[]), created_at (timestamptz)
// 2. pipelines: id (uuid PK), org_id (uuid FK), name (text), stages (jsonb DEFAULT '[{"name":"Lead","order":0},{"name":"Qualified","order":1},{"name":"Proposal","order":2},{"name":"Won","order":3},{"name":"Lost","order":4}]')
// 3. deals: id (uuid PK), contact_id (uuid FK), pipeline_id (uuid FK), stage (text), title (text), value (numeric), expected_close (date), owner_id (uuid FK), created_at (timestamptz), updated_at (timestamptz)
// 4. activities: id (uuid PK), deal_id (uuid FK), user_id (uuid FK), type (text CHECK in 'call','email','meeting','note'), content (text), created_at (timestamptz)
// Add RLS policies scoped by owner_id matching auth.uid().
```

> Pro tip: Use the Connect panel to add Supabase in two clicks — it auto-provisions NEXT_PUBLIC_SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY in the Vars tab. Then use V0 to generate the SQL migration.

**Expected result:** Supabase is connected with CRM tables created, pipeline stages configured as JSONB, and RLS policies restricting data by owner.

### 2. Build the Kanban deal board with drag-and-drop

Create the main deals page with a Kanban board layout. Each pipeline stage is a column, and deal Cards can be dragged between columns. Stage changes update the deal in Supabase via a Server Action with optimistic UI.

```
// Paste this prompt into V0's AI chat:
// Build a Kanban deal board at app/deals/page.tsx.
// Requirements:
// - 'use client' component using @hello-pangea/dnd for drag-and-drop
// - Columns for each pipeline stage (Lead, Qualified, Proposal, Won, Lost)
// - Deal Cards in each column showing: title, value (formatted as currency), contact name, expected close date, owner Avatar
// - Drag a Card between columns to change its stage
// - On drop, optimistically update the local state and call a Server Action to update deals.stage in Supabase
// - If the Server Action fails, revert the optimistic state and show an error toast
// - Column headers show the count of deals and total value
// - Add a "+" Button on each column header to create a new deal in that stage
// - Use shadcn/ui Card for deal tiles, Badge for stage labels, Avatar for owners
// - Add Tabs to switch between Kanban and list Table views
```

**Expected result:** The deal board shows pipeline stages as columns with draggable deal Cards. Dropping a Card in a new column updates the stage optimistically.

### 3. Create the contact management page with search and filtering

Build the contacts page with a searchable, filterable Table. Users can add new contacts via a Sheet slide-over, view contact details, and filter by tags and source.

```
import { createClient } from '@supabase/supabase-js'
import { ContactTable } from '@/components/contact-table'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'

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

export default async function ContactsPage() {
  const { data: contacts } = await supabase
    .from('contacts')
    .select('*, deals(id, title, value, stage)')
    .order('created_at', { ascending: false })

  const totalContacts = contacts?.length ?? 0
  const withDeals = contacts?.filter((c) => c.deals?.length > 0).length ?? 0

  return (
    <div className="space-y-6">
      <div className="flex items-center justify-between">
        <h1 className="text-3xl font-bold">Contacts</h1>
        <Button>Add Contact</Button>
      </div>
      <div className="grid gap-4 md:grid-cols-3">
        <Card>
          <CardHeader><CardTitle>Total Contacts</CardTitle></CardHeader>
          <CardContent className="text-3xl font-bold">{totalContacts}</CardContent>
        </Card>
        <Card>
          <CardHeader><CardTitle>With Active Deals</CardTitle></CardHeader>
          <CardContent className="text-3xl font-bold">{withDeals}</CardContent>
        </Card>
      </div>
      <ContactTable contacts={contacts ?? []} />
    </div>
  )
}
```

> Pro tip: Use Design Mode (Option+D) to adjust the Table column widths, contact Card spacing, and Badge colors for tags without spending any credits.

**Expected result:** The contacts page shows a searchable Table with stats Cards. The Sheet slide-over allows adding new contacts without navigating away.

### 4. Build the deal detail page with activity timeline

Create the deal detail page showing all deal information and a chronological activity timeline. Sales reps can log calls, emails, meetings, and notes. Activities are displayed in a vertical timeline with type-specific icons.

```
'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 updateDealStage(dealId: string, stage: string) {
  const { error } = await supabase
    .from('deals')
    .update({ stage, updated_at: new Date().toISOString() })
    .eq('id', dealId)

  if (error) throw new Error(error.message)
  revalidatePath('/deals')
}

export async function logActivity(
  dealId: string,
  userId: string,
  type: 'call' | 'email' | 'meeting' | 'note',
  content: string
) {
  const { error } = await supabase.from('activities').insert({
    deal_id: dealId,
    user_id: userId,
    type,
    content,
  })

  if (error) throw new Error(error.message)
  revalidatePath(`/deals/${dealId}`)
}

export async function createDeal(
  contactId: string,
  pipelineId: string,
  title: string,
  value: number,
  stage: string,
  ownerId: string
) {
  const { error } = await supabase.from('deals').insert({
    contact_id: contactId,
    pipeline_id: pipelineId,
    title,
    value,
    stage,
    owner_id: ownerId,
  })

  if (error) throw new Error(error.message)
  revalidatePath('/deals')
}
```

**Expected result:** The deal detail page shows deal info, contact details, and a timeline of activities. Users can log new activities with type selection and content.

### 5. Add pipeline revenue forecasting and global search

Build a forecasting view that calculates expected revenue based on deal values and stage probability weights. Add a global search using shadcn/ui Command palette to search across contacts and deals.

```
// Paste this prompt into V0's AI chat:
// Build two features:
// 1. Revenue forecast at app/forecast/page.tsx:
//    - Assign probability weights to pipeline stages: Lead=10%, Qualified=30%, Proposal=60%, Won=100%, Lost=0%
//    - Calculate weighted pipeline value: SUM(deal.value * stage_probability)
//    - Show summary Cards: Total Pipeline Value, Weighted Forecast, Deals Closing This Month
//    - BarChart (Recharts) showing deal values by stage
//    - Table of deals sorted by expected_close date with stage Badge and weighted value
// 2. Global search using shadcn/ui Command (Cmd+K):
//    - Search across contacts (by name, company, email) and deals (by title)
//    - Show results grouped by type with icons
//    - Navigate to contact or deal detail page on selection
//    - Use Supabase full-text search with to_tsvector on searchable columns
```

**Expected result:** The forecast page shows weighted pipeline revenue with charts. Cmd+K opens a global search across contacts and deals.

## Complete code example

File: `app/actions/crm.ts`

```typescript
'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 updateDealStage(dealId: string, stage: string) {
  const { error } = await supabase
    .from('deals')
    .update({ stage, updated_at: new Date().toISOString() })
    .eq('id', dealId)
  if (error) throw new Error(error.message)
  revalidatePath('/deals')
}

export async function logActivity(
  dealId: string,
  userId: string,
  type: 'call' | 'email' | 'meeting' | 'note',
  content: string
) {
  const { error } = await supabase.from('activities').insert({
    deal_id: dealId,
    user_id: userId,
    type,
    content,
  })
  if (error) throw new Error(error.message)
  revalidatePath(`/deals/${dealId}`)
}

export async function createContact(
  ownerId: string,
  name: string,
  company: string,
  email: string,
  phone: string,
  source: string
) {
  const { error } = await supabase.from('contacts').insert({
    owner_id: ownerId,
    name,
    company,
    email,
    phone,
    source,
  })
  if (error) throw new Error(error.message)
  revalidatePath('/contacts')
}

export async function createDeal(
  contactId: string,
  pipelineId: string,
  title: string,
  value: number,
  stage: string,
  ownerId: string
) {
  const { error } = await supabase.from('deals').insert({
    contact_id: contactId,
    pipeline_id: pipelineId,
    title,
    value,
    stage,
    owner_id: ownerId,
  })
  if (error) throw new Error(error.message)
  revalidatePath('/deals')
}
```

## Common mistakes

- **Not using optimistic updates for drag-and-drop stage changes** — If the UI waits for the Server Action to complete before moving the Card, there is a noticeable delay that makes the Kanban board feel sluggish and unresponsive. Fix: Update the local state immediately on drop using React state, then fire the Server Action in the background. If the action fails, revert the state and show an error toast.
- **Querying Supabase without owner_id filtering** — Without owner-scoped queries, users see all deals and contacts in the system, not just their own. RLS is the safety net, but explicit filtering improves performance. Fix: Always include .eq('owner_id', userId) in Supabase queries in addition to RLS policies. RLS is defense-in-depth, not a replacement for proper query scoping.
- **Storing pipeline stages as separate database rows instead of JSONB** — Separate rows require complex joins for every pipeline query and make reordering stages difficult. Fix: Store stages as a JSONB array in the pipelines table. This makes retrieval a single query and reordering is a simple array manipulation.

## Best practices

- Use @hello-pangea/dnd for Kanban drag-and-drop — it is the maintained fork of react-beautiful-dnd and works with React Server Components
- Store pipeline stages as JSONB for easy customization and ordering without complex table joins
- Use Server Components for contact lists and deal tables to keep database queries server-side
- Use RLS policies scoped by owner_id matching auth.uid() for multi-tenant data isolation
- Use Design Mode (Option+D) to adjust Kanban column widths, Card styling, and Badge colors without spending credits
- Log every customer interaction as an activity to build a complete relationship history
- Use revalidatePath after every mutation to keep the Kanban board and contact list current
- Implement global search with Supabase full-text search and shadcn/ui Command for a professional UX

## Frequently asked questions

### What is the best drag-and-drop library for a V0 Kanban board?

@hello-pangea/dnd is recommended — it is the actively maintained fork of react-beautiful-dnd with full React 18 support. V0 can install it via the project dependencies and generate the DragDropContext, Droppable, and Draggable components.

### How do I handle multi-user access in the CRM?

Use Supabase RLS policies scoped by owner_id matching auth.uid(). Each user only sees their own contacts and deals. For team access, add an org_id column and update policies to allow team members within the same organization.

### What V0 plan do I need for a CRM system?

V0 Premium is recommended because the CRM requires multiple complex pages (Kanban board, contacts, deal detail, forecast), drag-and-drop components, and several Server Actions.

### Can I customize the pipeline stages?

Yes. Stages are stored as a JSONB array in the pipelines table. Add a settings page where users can add, remove, and reorder stages by modifying the JSONB array via a Server Action.

### How do I deploy the CRM?

Click Share then Publish to Production in V0 for instant Vercel deployment. All data is stored in Supabase with RLS ensuring multi-user data isolation.

### Can I import existing contacts from a spreadsheet?

Yes. Add a CSV import page similar to the budgeting tool pattern — upload a CSV, parse it with papaparse, map columns to contact fields, and bulk-insert into Supabase.

### Can RapidDev help build a custom CRM?

Yes. RapidDev has built 600+ apps including CRMs with email integration, deal automation, multi-team pipelines, and advanced reporting. Book a free consultation to discuss your CRM requirements.

---

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