# How to Build a Task Management App with Lovable

- Tool: How to Build with Lovable
- Difficulty: Beginner
- Compatibility: Lovable (any plan), Supabase Free tier
- Last updated: April 2026

## TL;DR

Build a Kanban task management app in Lovable with drag-and-drop columns, task cards showing priority badges and assignee avatars, and a task detail Dialog. Task positions are updated atomically via a Supabase RPC function to prevent conflicts. Board members are managed with role-based RLS so only invited users can see each board.

## Before you start

- Lovable account (Free plan works)
- Supabase project with Auth enabled
- Supabase project URL and anon key ready
- Basic understanding of Lovable's chat prompt interface

## Step-by-step guide

### 1. Create the Kanban database schema with RPC for position updates

Run this SQL in Supabase. The reorder_tasks RPC function updates all task positions in a single transaction, preventing the race conditions that occur when dragging multiple cards quickly.

```
-- Run in Supabase SQL Editor
create table public.boards (
  id uuid primary key default gen_random_uuid(),
  name text not null,
  owner_id uuid not null references auth.users(id),
  created_at timestamptz not null default now()
);

create table public.board_members (
  id uuid primary key default gen_random_uuid(),
  board_id uuid not null references public.boards(id) on delete cascade,
  user_id uuid not null references auth.users(id),
  role text not null default 'member' check (role in ('owner','admin','member')),
  unique (board_id, user_id)
);

create table public.columns (
  id uuid primary key default gen_random_uuid(),
  board_id uuid not null references public.boards(id) on delete cascade,
  name text not null,
  position integer not null default 0
);

create table public.tasks (
  id uuid primary key default gen_random_uuid(),
  column_id uuid not null references public.columns(id) on delete cascade,
  board_id uuid not null references public.boards(id) on delete cascade,
  title text not null,
  description text,
  priority text not null default 'medium' check (priority in ('low','medium','high','urgent')),
  labels text[] not null default '{}',
  assignee_id uuid references auth.users(id),
  due_date date,
  position integer not null default 0,
  created_at timestamptz not null default now()
);

create table public.task_comments (
  id uuid primary key default gen_random_uuid(),
  task_id uuid not null references public.tasks(id) on delete cascade,
  user_id uuid not null references auth.users(id),
  body text not null,
  created_at timestamptz not null default now()
);

-- RPC: atomically reorder tasks when a card is dragged
create or replace function public.reorder_tasks(
  p_board_id uuid,
  p_column_id uuid,
  p_task_ids uuid[]
) returns void language plpgsql security definer as $$
declare
  i integer;
begin
  for i in 1..array_length(p_task_ids, 1) loop
    update public.tasks
    set position = i, column_id = p_column_id
    where id = p_task_ids[i] and board_id = p_board_id;
  end loop;
end;
$$;

alter table public.boards enable row level security;
alter table public.board_members enable row level security;
alter table public.columns enable row level security;
alter table public.tasks enable row level security;
alter table public.task_comments enable row level security;

-- Helper function to check board membership
create or replace function public.is_board_member(p_board_id uuid)
returns boolean language sql security definer as $$
  select exists (select 1 from public.board_members where board_id = p_board_id and user_id = auth.uid());
$$;

create policy "members_read_board" on public.boards for select to authenticated using (public.is_board_member(id));
create policy "members_read_columns" on public.columns for select to authenticated using (public.is_board_member(board_id));
create policy "members_all_tasks" on public.tasks for all to authenticated using (public.is_board_member(board_id)) with check (public.is_board_member(board_id));
create policy "members_comments" on public.task_comments for all to authenticated using (public.is_board_member((select board_id from public.tasks where id = task_id)));
create policy "owner_manage_members" on public.board_members for all to authenticated using (board_id in (select id from public.boards where owner_id = auth.uid()));
create policy "self_read_membership" on public.board_members for select to authenticated using (user_id = auth.uid());
```

> Pro tip: The reorder_tasks function uses security definer which means it runs with the privileges of the function creator (your Supabase service account). This lets it bypass RLS for the bulk update while the calling code is still checked by regular policies.

**Expected result:** Five tables and one RPC function appear in Supabase. The is_board_member helper function shows in Database → Functions. You can test the RPC in the Supabase SQL Editor: SELECT reorder_tasks('[board-id]', '[column-id]', ARRAY['[task-id-1]', '[task-id-2]']).

### 2. Scaffold the Kanban board UI with Lovable

Connect Supabase in Lovable's Cloud tab and send the prompt below to generate the board list, Kanban view, and task detail Dialog in one pass.

```
// Lovable prompt — paste into chat
// Build a Kanban task management app with Supabase and dnd-kit.
// Tables: boards, board_members, columns, tasks (priority, labels, assignee_id, due_date, position), task_comments.
// Pages:
//   /boards — list of boards the user is a member of (Card per board, name, member count, Create Board button).
//   /board/[id] — Kanban view: horizontal scroll of columns, each column is a Card header with column name and task count.
//     Tasks in each column: Card with title, priority Badge (low=outline, medium=secondary, high=default, urgent=destructive),
//     assignee Avatar, due date if set, label chips. Drag-and-drop cards between columns using dnd-kit.
//     Add Task button at bottom of each column opens a Dialog: title Input, description Textarea, priority Select,
//     due date Popover Calendar, assignee Select from board members.
//     Clicking a card opens task detail Sheet: full description, comments list, Add Comment form.
//     Each card has a DropdownMenu with: Edit, Move to column, Delete.
// On drag end, call supabase.rpc('reorder_tasks', ...) with the new column_id and ordered task IDs.
// Use shadcn/ui throughout. Supabase Realtime on tasks and task_comments channels.
```

> Pro tip: After generation, switch to Plan Mode and ask Lovable to review the drag-and-drop implementation — specifically that it uses optimistic updates (updating React state before the Supabase RPC confirms) so drags feel instant.

**Expected result:** Lovable generates the boards list page, board detail page with column layout, task cards, and detail Sheet. Preview shows a horizontal column layout with placeholder task cards.

### 3. Implement drag-and-drop with dnd-kit and optimistic updates

Wrap the board in a DndContext. On drag end, immediately update the local state (optimistic update), then call the Supabase RPC. If the RPC fails, roll back the state.

```
// src/components/KanbanBoard.tsx (drag logic)
import { useState, useCallback } from 'react'
import { DndContext, DragEndEvent, DragOverlay, useSensor, useSensors, PointerSensor } from '@dnd-kit/core'
import { SortableContext, arrayMove, verticalListSortingStrategy } from '@dnd-kit/sortable'
import { supabase } from '@/lib/supabase'
import { toast } from 'sonner'

type Task = { id: string; column_id: string; position: number; title: string; priority: string }
type Column = { id: string; name: string; position: number }

type Props = { boardId: string; columns: Column[]; initialTasks: Task[] }

export function KanbanBoard({ boardId, columns, initialTasks }: Props) {
  const [tasks, setTasks] = useState<Task[]>(initialTasks)
  const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } }))

  const tasksByColumn = useCallback(
    (colId: string) => tasks.filter(t => t.column_id === colId).sort((a, b) => a.position - b.position),
    [tasks]
  )

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

    const activeTask = tasks.find(t => t.id === active.id)
    if (!activeTask) return

    // Determine target column (over could be a column or another task)
    const targetColumnId = tasks.find(t => t.id === over.id)?.column_id ?? over.id as string
    const prevTasks = tasks

    // Optimistic update
    setTasks(prev => {
      const moved = prev.map(t => t.id === active.id ? { ...t, column_id: targetColumnId } : t)
      const colTasks = moved.filter(t => t.column_id === targetColumnId).sort((a, b) => a.position - b.position)
      const activeIdx = colTasks.findIndex(t => t.id === active.id)
      const overIdx = colTasks.findIndex(t => t.id === over.id)
      const reordered = activeIdx !== overIdx && overIdx !== -1 ? arrayMove(colTasks, activeIdx, overIdx) : colTasks
      const withPositions = reordered.map((t, i) => ({ ...t, position: i + 1 }))
      return moved.map(t => withPositions.find(w => w.id === t.id) ?? t)
    })

    // Persist atomically
    const colTasks = tasks
      .filter(t => t.column_id === targetColumnId || t.id === active.id)
      .map(t => ({ ...t, column_id: targetColumnId }))
      .sort((a, b) => a.position - b.position)
    const orderedIds = colTasks.map(t => t.id)

    const { error } = await supabase.rpc('reorder_tasks', {
      p_board_id: boardId,
      p_column_id: targetColumnId,
      p_task_ids: orderedIds
    })

    if (error) {
      setTasks(prevTasks) // rollback
      toast.error('Failed to save task order')
    }
  }

  return (
    <DndContext sensors={sensors} onDragEnd={handleDragEnd}>
      <div className="flex gap-4 overflow-x-auto p-4">
        {columns.map(col => (
          <div key={col.id} className="w-72 shrink-0">
            <SortableContext items={tasksByColumn(col.id).map(t => t.id)} strategy={verticalListSortingStrategy}>
              {/* Column render — TaskCard components go here */}
              <p className="font-medium mb-2">{col.name} ({tasksByColumn(col.id).length})</p>
              {tasksByColumn(col.id).map(task => (
                <div key={task.id} className="mb-2 p-3 bg-card border rounded-md text-sm">{task.title}</div>
              ))}
            </SortableContext>
          </div>
        ))}
      </div>
    </DndContext>
  )
}
```

> Pro tip: Set activationConstraint: { distance: 8 } on the PointerSensor so that clicking a card to open the detail Dialog doesn't accidentally trigger a drag. The 8px threshold gives users a clear gesture to distinguish click from drag.

**Expected result:** Cards can be dragged between columns. The UI updates instantly on drop. If you disconnect from the internet and drag, the optimistic update shows the new position, then reverts when the RPC fails.

### 4. Build the task card with Badge, Avatar, and DropdownMenu

Each task card shows priority as a colored Badge, the assignee Avatar, due date in a readable format, and a DropdownMenu triggered by a three-dot icon button.

```
// src/components/TaskCard.tsx
import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { Button } from '@/components/ui/button'
import { MoreHorizontal, Calendar } from 'lucide-react'
import { format } from 'date-fns'

type Task = { id: string; title: string; priority: string; labels: string[]; due_date?: string; assignee?: { full_name: string } }
type Props = { task: Task; onEdit: () => void; onDelete: () => void; onClick: () => void }

const priorityVariants: Record<string, 'outline' | 'secondary' | 'default' | 'destructive'> = {
  low: 'outline', medium: 'secondary', high: 'default', urgent: 'destructive'
}

export function TaskCard({ task, onEdit, onDelete, onClick }: Props) {
  const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: task.id })

  const style = {
    transform: CSS.Transform.toString(transform),
    transition,
    opacity: isDragging ? 0.5 : 1
  }

  return (
    <div ref={setNodeRef} style={style} {...attributes}>
      <Card className="mb-2 cursor-pointer hover:shadow-md transition-shadow">
        <CardContent className="p-3 space-y-2">
          <div className="flex items-start justify-between gap-2">
            <p {...listeners} className="text-sm font-medium flex-1 cursor-grab active:cursor-grabbing" onClick={onClick}>{task.title}</p>
            <DropdownMenu>
              <DropdownMenuTrigger asChild>
                <Button variant="ghost" size="icon" className="h-6 w-6 shrink-0">
                  <MoreHorizontal className="h-3 w-3" />
                </Button>
              </DropdownMenuTrigger>
              <DropdownMenuContent align="end">
                <DropdownMenuItem onClick={onEdit}>Edit</DropdownMenuItem>
                <DropdownMenuItem onClick={onDelete} className="text-destructive">Delete</DropdownMenuItem>
              </DropdownMenuContent>
            </DropdownMenu>
          </div>
          <div className="flex flex-wrap gap-1">
            <Badge variant={priorityVariants[task.priority]} className="text-xs">{task.priority}</Badge>
            {task.labels.map(label => <Badge key={label} variant="outline" className="text-xs">{label}</Badge>)}
          </div>
          <div className="flex items-center justify-between">
            {task.due_date && (
              <span className="flex items-center gap-1 text-xs text-muted-foreground">
                <Calendar className="h-3 w-3" />
                {format(new Date(task.due_date), 'MMM d')}
              </span>
            )}
            {task.assignee && (
              <Avatar className="h-6 w-6">
                <AvatarFallback className="text-xs">{task.assignee.full_name.slice(0, 2).toUpperCase()}</AvatarFallback>
              </Avatar>
            )}
          </div>
        </CardContent>
      </Card>
    </div>
  )
}
```

> Pro tip: Attach the drag listeners only to the card title element (with ...listeners on the p tag) rather than the entire card. This lets users click the DropdownMenu trigger and the card body without accidentally starting a drag.

**Expected result:** Task cards render with a colored priority Badge, optional label chips, due date with calendar icon, and assignee initials Avatar. The three-dot DropdownMenu opens on click without triggering drag.

### 5. Add Supabase Realtime for live board updates

Subscribe to changes on the tasks table filtered to the current board_id so all board members see card movements and new tasks appear live without refreshing.

```
// src/hooks/useBoardRealtime.ts
import { useEffect } from 'react'
import { supabase } from '@/lib/supabase'

type Task = { id: string; column_id: string; position: number; title: string; priority: string; labels: string[] }

export function useBoardRealtime(
  boardId: string,
  setTasks: React.Dispatch<React.SetStateAction<Task[]>>
) {
  useEffect(() => {
    const channel = supabase
      .channel(`board-${boardId}`)
      .on(
        'postgres_changes',
        { event: '*', schema: 'public', table: 'tasks', filter: `board_id=eq.${boardId}` },
        payload => {
          if (payload.eventType === 'INSERT') {
            setTasks(prev => [...prev, payload.new as Task])
          } else if (payload.eventType === 'UPDATE') {
            setTasks(prev => prev.map(t => t.id === payload.new.id ? { ...t, ...payload.new } as Task : t))
          } else if (payload.eventType === 'DELETE') {
            setTasks(prev => prev.filter(t => t.id !== payload.old.id))
          }
        }
      )
      .subscribe()

    return () => { supabase.removeChannel(channel) }
  }, [boardId, setTasks])
}
```

> Pro tip: Enable Supabase Realtime on the tasks table in your Supabase project Dashboard → Database → Replication. The table must be listed in the publication for postgres_changes events to fire.

**Expected result:** Opening the same board in two browser tabs and dragging a card in one tab causes it to move in the other tab within 1-2 seconds, without any refresh.

## Complete code example

File: `src/components/TaskCard.tsx`

```typescript
import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { Button } from '@/components/ui/button'
import { MoreHorizontal, Calendar, GripVertical } from 'lucide-react'
import { format, isPast, isToday } from 'date-fns'
import { cn } from '@/lib/utils'

type Task = {
  id: string; title: string
  priority: 'low' | 'medium' | 'high' | 'urgent'
  labels: string[]; due_date?: string | null
  assignee?: { full_name: string } | null
}
type Props = { task: Task; onEdit: (t: Task) => void; onDelete: (id: string) => void; onClick: (t: Task) => void }

const priorityVariants: Record<Task['priority'], 'outline' | 'secondary' | 'default' | 'destructive'> = {
  low: 'outline', medium: 'secondary', high: 'default', urgent: 'destructive'
}

export function TaskCard({ task, onEdit, onDelete, onClick }: Props) {
  const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: task.id })
  const style = { transform: CSS.Transform.toString(transform), transition, zIndex: isDragging ? 50 : undefined }
  const dueDate = task.due_date ? new Date(task.due_date) : null
  const dueSoon = dueDate && (isToday(dueDate) || isPast(dueDate))

  return (
    <div ref={setNodeRef} style={style} className={cn(isDragging && 'opacity-50')}>
      <Card className="mb-2 hover:shadow-md transition-shadow group">
        <CardContent className="p-3 space-y-2">
          <div className="flex items-start gap-1">
            <button {...listeners} {...attributes}
              className="mt-0.5 cursor-grab active:cursor-grabbing text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity p-0.5 rounded"
              aria-label="Drag to reorder">
              <GripVertical className="h-4 w-4" />
            </button>
            <button className="text-sm font-medium flex-1 text-left hover:text-primary" onClick={() => onClick(task)}>
              {task.title}
            </button>
            <DropdownMenu>
              <DropdownMenuTrigger asChild>
                <Button variant="ghost" size="icon" className="h-6 w-6 opacity-0 group-hover:opacity-100" onClick={e => e.stopPropagation()}>
                  <MoreHorizontal className="h-3 w-3" />
                </Button>
              </DropdownMenuTrigger>
              <DropdownMenuContent align="end">
                <DropdownMenuItem onClick={() => onEdit(task)}>Edit task</DropdownMenuItem>
                <DropdownMenuItem onClick={() => onDelete(task.id)} className="text-destructive">Delete</DropdownMenuItem>
              </DropdownMenuContent>
            </DropdownMenu>
          </div>
          {(task.labels.length > 0 || task.priority !== 'medium') && (
            <div className="flex flex-wrap gap-1">
              <Badge variant={priorityVariants[task.priority]} className="text-xs h-5">{task.priority}</Badge>
              {task.labels.slice(0, 3).map(l => <Badge key={l} variant="outline" className="text-xs h-5">{l}</Badge>)}
            </div>
          )}
          <div className="flex items-center justify-between">
            {dueDate && (
              <span className={cn('flex items-center gap-1 text-xs', dueSoon ? 'text-destructive font-medium' : 'text-muted-foreground')}>
                <Calendar className="h-3 w-3" />{format(dueDate, 'MMM d')}
              </span>
            )}
            {task.assignee && (
              <Avatar className="h-6 w-6 ml-auto">
                <AvatarFallback className="text-xs bg-primary/10">
                  {task.assignee.full_name.split(' ').map(n => n[0]).join('').slice(0, 2).toUpperCase()}
                </AvatarFallback>
              </Avatar>
            )}
          </div>
        </CardContent>
      </Card>
    </div>
  )
}
```

## Common mistakes

- **Updating task positions one row at a time instead of using the RPC** — When a user drags a card to the middle of a column, every card below it needs its position decremented. Doing this with individual update calls in a loop creates a race condition if the user drags again before all updates complete. Fix: Always call the reorder_tasks RPC function which handles all position updates in a single database transaction. The function signature: supabase.rpc('reorder_tasks', { p_board_id, p_column_id, p_task_ids }).
- **Skipping optimistic UI and waiting for the Supabase RPC to confirm** — The Supabase RPC round-trip takes 100-300ms. Without optimistic updates, the card visually jumps back to its original position and then snaps to the new one, creating a jarring user experience. Fix: Update React state immediately in handleDragEnd before calling the RPC. Only roll back to the previous state if the RPC returns an error.
- **Not adding dnd-kit activationConstraint** — Without a minimum distance constraint, any mousedown on a card starts a drag. Clicking to open the task detail Dialog or clicking the DropdownMenu trigger becomes unreliable. Fix: Add activationConstraint: { distance: 8 } to the PointerSensor so a drag only activates after the pointer moves 8 pixels.
- **Forgetting to add the board_id to the tasks table** — Without board_id on tasks, the RLS policy cannot check board membership and every board member query needs an expensive JOIN through the columns table. Fix: Store board_id directly on the tasks table as a denormalized column for fast RLS evaluation and Realtime filtering.
- **Using a global Realtime subscription instead of a board-scoped one** — Subscribing to all changes on the tasks table means every user receives updates for boards they are not members of, leaking task data and causing unnecessary re-renders. Fix: Add a filter parameter to the Realtime subscription: .on('postgres_changes', { event: '*', schema: 'public', table: 'tasks', filter: 'board_id=eq.YOUR_BOARD_ID' }, ...).

## Best practices

- Store position as a small integer (1, 2, 3...) and always recalculate all positions in the affected column on drag — never try to insert a position between two existing values with fractional numbers.
- Denormalize board_id onto the tasks table even though it is technically derivable through column — it makes RLS policies and Realtime filters simpler and faster.
- Show a GripVertical icon on card hover instead of making the entire card draggable — this makes the drag affordance clearer and prevents accidental drags.
- Implement board member invitations using Supabase Auth's inviteUserByEmail and insert a board_members row when the user accepts, rather than creating pre-populated accounts.
- Limit label length and count per task (3 labels max, 20 chars each) to keep card height predictable and the Kanban layout visually consistent.
- Add keyboard shortcuts for common actions: N for new task in focused column, E to edit the focused card, Delete to delete — these are expected by power users.
- Cap the number of tasks per column with a database constraint or client-side warning at 50 rows per column — very long columns degrade the Kanban experience.
- Test drag-and-drop on a touch device since dnd-kit requires the TouchSensor in addition to PointerSensor for mobile support.

## Frequently asked questions

### Why do I need a Supabase RPC function for reordering instead of just updating rows?

When you drag a card to the middle of a column, multiple rows need position updates simultaneously. Doing separate update calls creates race conditions if the user drags again before all calls complete. The RPC function wraps all updates in a single transaction, making the reorder atomic.

### How do I invite a team member to a board?

Use Supabase Auth's inviteUserByEmail to send an invitation, then insert a board_members row with their user ID and the board ID once they accept. Add an invite button in the board settings that opens a Dialog with an email Input and calls both APIs.

### Can non-members see the board if they have the URL?

No. The RLS policy on boards uses the is_board_member helper function which checks the board_members table. Users not in that table receive an empty result set from Supabase — the board data is never sent to their browser.

### How do I make drag-and-drop work on mobile?

Add dnd-kit's TouchSensor alongside PointerSensor in your useSensors setup: useSensors(useSensor(PointerSensor, ...), useSensor(TouchSensor, { activationConstraint: { delay: 250, tolerance: 8 } })). The delay prevents scroll conflicts on touch screens.

### Why does the card flash back to its original position when I drag?

This happens when optimistic UI is not implemented — the card reverts to server state before the Supabase update confirms. Update your local tasks state immediately in handleDragEnd before calling the RPC, and only roll back if the RPC returns an error.

### How do I deploy the board so my whole team can use it?

Click the Publish icon in Lovable to get your production URL. Share that URL with your team. Each member creates a Supabase account via the signup page and you invite them to the board from the board settings page.

### Can I export the board to CSV or connect it to other tools?

Add an Export button that queries tasks and formats them as CSV using the browser's Blob API. For external connections like Slack or Notion, create a Supabase Edge Function triggered by database webhooks that posts updates when task status changes.

### Can RapidDev help me add advanced features like time tracking or Gantt charts?

Yes. RapidDev specializes in extending Lovable Kanban apps with features like time tracking, subtask dependencies, Gantt chart views, and third-party integrations. Visit rapiddev.io to discuss your requirements.

---

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