# How to Build Task management app with V0

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

## TL;DR

Build a Trello-style task management app with V0 featuring kanban boards, drag-and-drop using dnd-kit, task assignment with avatars, due dates, priority badges, and real-time board updates. You'll create project workspaces with list and board views using Next.js and Supabase — 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)
- Supabase Auth configured for user authentication
- Basic understanding of kanban boards (columns with cards you can drag between)

## Step-by-step guide

### 1. Set up the project and task database schema

Open V0 and create a new project. Use the Connect panel to add Supabase. Create the projects, columns, tasks, and project_members tables with position columns for drag-and-drop ordering.

```
// Paste this prompt into V0's AI chat:
// Create a Supabase schema for a task management app:
// 1. projects table: id (uuid PK), name (text), description (text), owner_id (uuid FK), created_at (timestamptz)
// 2. columns table: id (uuid PK), project_id (uuid FK), name (text — 'To Do', 'In Progress', 'Review', 'Done'), position (int for ordering)
// 3. tasks table: id (uuid PK), column_id (uuid FK), title (text NOT NULL), description (text), assignee_id (uuid FK nullable), priority (text DEFAULT 'medium'), due_date (date nullable), position (int for ordering within column), created_at (timestamptz), updated_at (timestamptz)
// 4. project_members table: project_id (uuid FK), user_id (uuid FK), role (text DEFAULT 'member'), PRIMARY KEY(project_id, user_id)
// RLS: project_members can access their project's columns and tasks.
// Seed a sample project with 4 columns and 8 tasks across columns.
```

> Pro tip: Use V0's prompt queuing — queue the schema prompt, then 'add @dnd-kit/core and @dnd-kit/sortable to the project', then the kanban board component prompt.

**Expected result:** Four tables created with RLS policies, a sample project seeded with 4 columns and 8 tasks distributed across them.

### 2. Build the kanban board with drag-and-drop

Create the kanban board as a client component using @dnd-kit for drag-and-drop between columns. Each column is a droppable area, each task card is draggable. Dropping a task updates its column_id and position.

```
'use client'

import { useState } from 'react'
import { DndContext, closestCorners, DragEndEvent } from '@dnd-kit/core'
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area'
import { moveTask } from '@/app/actions/tasks'

type Task = {
  id: string
  title: string
  priority: string
  assignee_id: string | null
  due_date: string | null
  position: number
  column_id: string
}

type Column = {
  id: string
  name: string
  position: number
  tasks: Task[]
}

const priorityColors: Record<string, string> = {
  high: 'bg-red-100 text-red-800',
  medium: 'bg-yellow-100 text-yellow-800',
  low: 'bg-green-100 text-green-800',
}

export function KanbanBoard({ initialColumns }: { initialColumns: Column[] }) {
  const [columns, setColumns] = useState(initialColumns)

  async function handleDragEnd(event: DragEndEvent) {
    const { active, over } = event
    if (!over) return

    const taskId = active.id as string
    const targetColumnId = over.id as string

    // Optimistic update
    setColumns((prev) => {
      const updated = prev.map((col) => ({
        ...col,
        tasks: col.tasks.filter((t) => t.id !== taskId),
      }))
      const targetCol = updated.find((c) => c.id === targetColumnId)
      const task = prev.flatMap((c) => c.tasks).find((t) => t.id === taskId)
      if (targetCol && task) {
        targetCol.tasks.push({ ...task, column_id: targetColumnId, position: targetCol.tasks.length })
      }
      return updated
    })

    await moveTask(taskId, targetColumnId, 0)
  }

  return (
    <DndContext collisionDetection={closestCorners} onDragEnd={handleDragEnd}>
      <div className="flex gap-4 overflow-x-auto p-4">
        {columns.map((column) => (
          <div key={column.id} className="w-72 flex-shrink-0">
            <h3 className="font-semibold mb-3 flex items-center gap-2">
              {column.name}
              <Badge variant="secondary">{column.tasks.length}</Badge>
            </h3>
            <SortableContext items={column.tasks.map((t) => t.id)} strategy={verticalListSortingStrategy}>
              <ScrollArea className="space-y-2">
                {column.tasks.map((task) => (
                  <Card key={task.id} className="mb-2 cursor-grab">
                    <CardContent className="p-3">
                      <p className="font-medium text-sm">{task.title}</p>
                      <div className="flex items-center justify-between mt-2">
                        <Badge className={priorityColors[task.priority]} variant="secondary">
                          {task.priority}
                        </Badge>
                        {task.assignee_id && (
                          <Avatar className="w-6 h-6">
                            <AvatarFallback className="text-xs">U</AvatarFallback>
                          </Avatar>
                        )}
                      </div>
                    </CardContent>
                  </Card>
                ))}
              </ScrollArea>
            </SortableContext>
            <Button variant="ghost" size="sm" className="w-full mt-2">+ Add task</Button>
          </div>
        ))}
      </div>
    </DndContext>
  )
}
```

> Pro tip: Use Design Mode (Option+D) to visually adjust card sizing, column widths, and the color palette for priority Badges at zero credit cost.

**Expected result:** A kanban board with four columns. Task cards are draggable between columns. Dropping a card immediately updates the UI and persists the change via Server Action.

### 3. Create Server Actions for task operations

Build Server Actions for creating tasks, moving tasks between columns, updating task details, and deleting tasks. The moveTask action updates both column_id and position atomically.

```
'use server'

import { createClient } from '@/lib/supabase/server'
import { revalidatePath } from 'next/cache'

export async function createTask(projectId: string, columnId: string, title: string) {
  const supabase = await createClient()
  const { data: maxPos } = await supabase
    .from('tasks')
    .select('position')
    .eq('column_id', columnId)
    .order('position', { ascending: false })
    .limit(1)
    .single()

  await supabase.from('tasks').insert({
    column_id: columnId,
    title,
    position: (maxPos?.position ?? 0) + 1,
  })
  revalidatePath(`/projects/${projectId}`)
}

export async function moveTask(taskId: string, newColumnId: string, newPosition: number) {
  const supabase = await createClient()
  await supabase.from('tasks').update({
    column_id: newColumnId,
    position: newPosition,
    updated_at: new Date().toISOString(),
  }).eq('id', taskId)
}

export async function updateTask(taskId: string, data: {
  title?: string
  description?: string
  priority?: string
  assignee_id?: string | null
  due_date?: string | null
}) {
  const supabase = await createClient()
  await supabase.from('tasks').update({
    ...data,
    updated_at: new Date().toISOString(),
  }).eq('id', taskId)
}

export async function deleteTask(taskId: string, projectId: string) {
  const supabase = await createClient()
  await supabase.from('tasks').delete().eq('id', taskId)
  revalidatePath(`/projects/${projectId}`)
}
```

**Expected result:** Server Actions handle all task CRUD operations. moveTask updates column_id and position for drag-and-drop persistence.

### 4. Build the task detail editor Dialog

Create a Dialog component that opens when clicking a task card. It shows the full task details with editable fields for title, description, priority, assignee, and due date.

```
// Paste this prompt into V0's AI chat:
// Build a task detail editor Dialog component.
// When a user clicks a task card on the kanban board, open a shadcn/ui Dialog with:
// - Input for task title (editable)
// - Textarea for description
// - Select for priority (low, medium, high)
// - Select for assignee (list of project members with Avatar)
// - Calendar date picker for due date
// - Badge showing current column/status
// - Button row: Save, Delete (with AlertDialog confirmation), Close
// - Activity feed section showing when the task was created, moved, and edited
// Use Server Action updateTask for saving and deleteTask for removing.
// Use Form component for proper form handling.
```

**Expected result:** A Dialog editor with editable task fields, Calendar date picker, assignee Select with member Avatars, and Save/Delete buttons.

### 5. Add the project list and alternative list view

Create the project list page and add a Tabs toggle on the board page to switch between kanban board and list view. The list view shows tasks in a sortable Table.

```
// Paste this prompt into V0's AI chat:
// Build two views for the task management app:
// 1. Project list page at app/projects/page.tsx:
//    - Server Component fetching all projects for the current user via project_members
//    - Card for each project showing name, description, task count, member Avatars
//    - "New Project" Button that opens a Dialog with Form for name and description
// 2. Add Tabs to app/projects/[id]/page.tsx to toggle between Board and List views:
//    - Board tab: the KanbanBoard component
//    - List tab: Table with columns for Title, Status (column name), Priority Badge, Assignee Avatar, Due Date, Actions DropdownMenu
//    - Table rows should be sortable by clicking column headers
// Use Server Components for data fetching, client components only for interactive elements.
```

**Expected result:** A project list page with Cards and a new project Dialog. The board page has Tabs switching between kanban board and sortable Table list view.

## Complete code example

File: `app/actions/tasks.ts`

```typescript
'use server'

import { createClient } from '@/lib/supabase/server'
import { revalidatePath } from 'next/cache'

export async function createTask(
  projectId: string,
  columnId: string,
  title: string
) {
  const supabase = await createClient()

  const { data: maxPos } = await supabase
    .from('tasks')
    .select('position')
    .eq('column_id', columnId)
    .order('position', { ascending: false })
    .limit(1)
    .single()

  await supabase.from('tasks').insert({
    column_id: columnId,
    title,
    position: (maxPos?.position ?? 0) + 1,
  })

  revalidatePath(`/projects/${projectId}`)
}

export async function moveTask(
  taskId: string,
  newColumnId: string,
  newPosition: number
) {
  const supabase = await createClient()
  await supabase
    .from('tasks')
    .update({
      column_id: newColumnId,
      position: newPosition,
      updated_at: new Date().toISOString(),
    })
    .eq('id', taskId)
}

export async function updateTask(
  taskId: string,
  projectId: string,
  data: Record<string, unknown>
) {
  const supabase = await createClient()
  await supabase
    .from('tasks')
    .update({ ...data, updated_at: new Date().toISOString() })
    .eq('id', taskId)
  revalidatePath(`/projects/${projectId}`)
}

export async function deleteTask(taskId: string, projectId: string) {
  const supabase = await createClient()
  await supabase.from('tasks').delete().eq('id', taskId)
  revalidatePath(`/projects/${projectId}`)
}
```

## Common mistakes

- **Not using optimistic updates for drag-and-drop** — Without optimistic updates, the card snaps back to its original position after dropping, waits for the server response, then jumps to the new position. This feels laggy and broken. Fix: Immediately update local state on drop, then fire the Server Action in the background. If the action fails, rollback to the snapshot state.
- **Using floating-point numbers for position ordering** — Fractional positions like 1.5, 1.25, 1.125 lose precision after many reorderings. Eventually two items get the same position. Fix: Use integer positions with gaps (10, 20, 30). When inserting between 10 and 20, use 15. Periodically renormalize positions with a batch update.
- **Not scoping RLS to project membership** — Without proper RLS, any authenticated user could read or modify any project's tasks by guessing UUIDs. Fix: Create RLS policies that check project_members: SELECT on tasks requires the user to be a member of the task's column's project.

## Best practices

- Use @dnd-kit for drag-and-drop — it is the most flexible React DnD library and works well with shadcn/ui Card components
- Implement optimistic drag-and-drop by updating local state immediately and persisting via Server Action in the background
- Use RLS policies joined through project_members to ensure users only access their own projects' tasks
- Use Design Mode (Option+D) to visually adjust Card sizing, column widths, and priority Badge colors at zero credit cost
- Use integer positions with gaps (10, 20, 30) for ordering to make reordering efficient without recalculating all positions
- Add Tabs for board vs list view to accommodate different user preferences — some people prefer tables over kanban
- Use connection pooling via Supavisor for concurrent board updates when multiple team members drag tasks simultaneously

## Frequently asked questions

### What drag-and-drop library does this use?

@dnd-kit is a lightweight, flexible React drag-and-drop library. Install it by prompting V0: 'add @dnd-kit/core and @dnd-kit/sortable'. It handles both cross-column dragging and within-column reordering.

### How does the position ordering work?

Each task has an integer position column. Tasks within a column are ordered by position ascending. When a task is dropped, its position is updated to reflect the new order. Using gaps (10, 20, 30) allows inserting between positions without recalculating all positions.

### What V0 plan do I need?

V0 Free tier works. The kanban board uses standard React components with @dnd-kit, shadcn/ui Cards, and Supabase. Design Mode for visual polish is also free.

### Can multiple team members use the board simultaneously?

Yes. Each user's drag-and-drop updates persist independently via Server Actions. For real-time sync, add Supabase Realtime subscriptions on the tasks table so other users see board changes instantly.

### How do I add more columns to the board?

Insert a new row into the columns table with the project_id and a position value. The board component queries columns ordered by position, so new columns appear automatically.

### Can RapidDev help build a custom project management tool?

Yes. RapidDev has built 600+ apps including project management platforms with Gantt charts, resource allocation, and sprint planning. Book a free consultation to discuss your workflow requirements.

### How do I deploy this to production?

Click Share > Publish in V0. The Supabase connection is auto-configured from the Connect panel. The kanban board works identically in production — drag-and-drop, real-time updates, and all.

---

Source: https://www.rapidevelopers.com/how-to-build-v0/task-management-app
© RapidDev — https://www.rapidevelopers.com/how-to-build-v0/task-management-app
