Kanban / To-Do List
- Build a project board with boards, columns, cards, drag-and-drop, and checklists
Prerequisite
Complete the Get Started guide first.
A kanban board is how teams organize work visually — moving cards through stages from "to do" to "done". Think project management, sprint planning, content pipelines, personal productivity, or any workflow where tasks move through distinct phases.
By the end of this example you will have:
- Boards — multiple boards for different projects or workflows
- Columns — customizable stages with drag-and-drop reordering
- Cards — tasks with descriptions, assignees, labels, due dates, and checklists
- Board dashboard — stats showing card counts, overdue items, and team workload
- Realtime — all changes sync instantly across connected browsers
Database Schema and Permissions
Prompt
We are building a kanban board app on top of this template. Create the
database schema and add the permissions we need.
Database (via Supabase MCP):
Create five tables:
1. `boards` table — id (uuid, default gen_random_uuid(), primary key),
team_id (uuid, references teams(id) on delete cascade, not null),
name (text, not null),
description (text, nullable),
color (text, default '#3b82f6', not null),
is_archived (boolean, default false, not null),
created_by (uuid, references profiles(id), not null),
created_at (timestamptz, default now()),
updated_at (timestamptz, default now()).
2. `columns` table — id (uuid, default gen_random_uuid(), primary key),
board_id (uuid, references boards(id) on delete cascade, not null),
name (text, not null),
sort_order (integer, default 0, not null),
card_limit (integer, nullable),
created_at (timestamptz, default now()),
updated_at (timestamptz, default now()).
3. `cards` table — id (uuid, default gen_random_uuid(), primary key),
column_id (uuid, references columns(id) on delete cascade, not null),
board_id (uuid, references boards(id) on delete cascade, not null),
title (text, not null),
description (text, nullable),
sort_order (integer, default 0, not null),
assigned_to (uuid, references profiles(id) on delete set null, nullable),
priority (text, check priority in ('low', 'normal', 'high', 'urgent'),
default 'normal', not null),
due_date (date, nullable),
labels (text[], default '{}', not null),
is_archived (boolean, default false, not null),
created_by (uuid, references profiles(id), not null),
created_at (timestamptz, default now()),
updated_at (timestamptz, default now()).
4. `card_comments` table — id (uuid, default gen_random_uuid(), primary key),
card_id (uuid, references cards(id) on delete cascade, not null),
user_id (uuid, references profiles(id), not null),
content (text, not null),
created_at (timestamptz, default now()).
5. `card_checklist_items` table — id (uuid, default gen_random_uuid(),
primary key),
card_id (uuid, references cards(id) on delete cascade, not null),
title (text, not null),
is_completed (boolean, default false, not null),
sort_order (integer, default 0, not null),
created_at (timestamptz, default now()),
updated_at (timestamptz, default now()).
Enable RLS on all five tables. Create policies that use the existing
`is_team_member()` function to scope access. For `boards`, check team_id.
For `columns` and `cards`, join through `boards` to check team membership.
For `card_comments` and `card_checklist_items`, join through `cards` and
`boards`.
All policies should allow select, insert, update, and delete for team
members. We will handle finer-grained permission checks in the server
routes, not in RLS.
After creating the tables, add these permissions to `shared/permissions.ts`:
```
"boards.view": ["owner", "admin", "member"],
"boards.create": ["owner", "admin", "member"],
"boards.update": ["owner", "admin"],
"boards.delete": ["owner", "admin"],
"cards.view": ["owner", "admin", "member"],
"cards.create": ["owner", "admin", "member"],
"cards.update": ["owner", "admin", "member"],
"cards.delete": ["owner", "admin"],
```
Regenerate the TypeScript types via Supabase MCP and save them to
`shared/types/database.types.ts`.Adding plugins later?
If you plan to add API Keys & External API or AI Chat later, their pages will guide you through adding the required permissions. You don't need to add them now.
Boards & Columns
Prompt
Build the board and column management — the structural layer of the kanban.
Server routes (all use `authUser` with the appropriate permission):
- `GET /api/teams/[teamId]/boards` — uses `authUser(event, "boards.view")`.
Returns all non-archived boards for the team, ordered by name asc. Include
a count of columns and total card count per board by joining. Support
optional query param `?include_archived=true` to also return archived boards.
- `POST /api/teams/[teamId]/boards` — uses
`authUser(event, "boards.create")`. Reads { name, description, color }
from the body. Validates that name is required. Sets team_id from auth
context and created_by from the authenticated user (user.sub). After
creating the board, create three default columns: "To Do" (sort_order 0),
"In Progress" (sort_order 1), "Done" (sort_order 2).
- `PATCH /api/teams/[teamId]/boards/[boardId]` — uses
`authUser(event, "boards.update")`. Updates whichever fields are provided.
Validates that the board belongs to the team.
- `DELETE /api/teams/[teamId]/boards/[boardId]` — uses
`authUser(event, "boards.delete")`. Validates that the board belongs to
the team before deleting.
- `GET /api/teams/[teamId]/boards/[boardId]/columns` — uses
`authUser(event, "boards.view")`. Returns all columns for the board,
ordered by sort_order asc. Include a count of non-archived cards per column.
- `POST /api/teams/[teamId]/boards/[boardId]/columns` — uses
`authUser(event, "boards.update")`. Reads { name, card_limit } from the
body. Validates that name is required. Sets sort_order to max existing
sort_order + 1 for this board.
- `PATCH /api/teams/[teamId]/boards/[boardId]/columns/[columnId]` — uses
`authUser(event, "boards.update")`. Updates name, card_limit, or
sort_order. Validates that the column belongs to the board.
- `DELETE /api/teams/[teamId]/boards/[boardId]/columns/[columnId]` — uses
`authUser(event, "boards.update")`. Return 400 if the column still has
cards. Validates board membership.
- `POST /api/teams/[teamId]/boards/[boardId]/columns/reorder` — uses
`authUser(event, "boards.update")`. Reads { column_ids } from the body
(an ordered array of column UUIDs). Updates the sort_order of each column
to match its array index. Validates that all columns belong to the board.
UI:
Create `app/pages/boards/index.vue` — a board list page as a top-level
route, wrapped in a `UDashboardPanel`:
- `UDashboardNavbar` in the header: title "Boards",
`UDashboardSidebarCollapse` on the left. On the right, a "New Board"
button wrapped in `CanAccess permission="boards.create"`.
- The body shows a grid of board cards (not a table). Each card shows the
board name, description (truncated), color accent bar on the left or top,
column count, and card count. Use `USkeleton` for loading. Show empty state.
- "New Board" button opens a `UModal` with a form: name (required),
description, color (preset color picker with 6-8 color options). Use Zod
for validation. Show loading on submit.
- Clicking a board card navigates to `app/pages/boards/[boardId].vue` —
the kanban board view (built in the next prompt).
- Board settings: a gear icon on each board card (wrapped in
`CanAccess permission="boards.update"`) that opens a `UModal` for
editing name, description, color, and archiving. Include delete wrapped
in `CanAccess permission="boards.delete"` with confirmation.
Sidebar navigation:
Add a "Boards" link to the top navigation group in
`app/components/layout/sidebar/Links.vue`, between "Dashboard" and
"Settings". Use the icon `i-solar-widget-2-bold-duotone`.Cards (Kanban Board View)
Prompt
Build the kanban board view and card management — the main interactive feature.
Server routes (all use `authUser` with the appropriate permission):
- `GET /api/teams/[teamId]/boards/[boardId]/cards` — uses
`authUser(event, "cards.view")`. Returns all non-archived cards for the
board with assignee name joined in, grouped by column. Order by sort_order
asc within each column. Support optional query params: `?assigned_to=`
to filter by assignee, `?priority=` to filter by priority,
`?include_archived=true`.
- `POST /api/teams/[teamId]/boards/[boardId]/cards` — uses
`authUser(event, "cards.create")`. Reads { column_id, title, description,
assigned_to, priority, due_date, labels } from the body. Validates that
column_id and title are required. Sets board_id from the route, sort_order
to max existing sort_order + 1 for that column, and created_by from the
authenticated user.
- `PATCH /api/teams/[teamId]/boards/[boardId]/cards/[cardId]` — uses
`authUser(event, "cards.update")`. Updates whichever fields are provided.
Validates that the card belongs to the board.
- `DELETE /api/teams/[teamId]/boards/[boardId]/cards/[cardId]` — uses
`authUser(event, "cards.delete")`. Validates board membership.
- `POST /api/teams/[teamId]/boards/[boardId]/cards/move` — uses
`authUser(event, "cards.update")`. Reads { card_id, target_column_id,
target_sort_order } from the body. Moves the card to the target column
at the specified sort position. Reorders other cards in both the source
and target columns to fill gaps and make room.
- `GET /api/teams/[teamId]/boards/[boardId]/cards/[cardId]/comments` — uses
`authUser(event, "cards.view")`. Returns all comments with author name,
ordered by created_at asc.
- `POST /api/teams/[teamId]/boards/[boardId]/cards/[cardId]/comments` — uses
`authUser(event, "cards.view")`. Any team member can comment. Reads
{ content } from body. Sets user_id from authenticated user.
- `GET /api/teams/[teamId]/boards/[boardId]/cards/[cardId]/checklist` — uses
`authUser(event, "cards.view")`. Returns all checklist items ordered by
sort_order asc.
- `POST /api/teams/[teamId]/boards/[boardId]/cards/[cardId]/checklist` — uses
`authUser(event, "cards.update")`. Reads { title } from body.
- `PATCH /api/teams/[teamId]/boards/[boardId]/cards/[cardId]/checklist/[itemId]`
— uses `authUser(event, "cards.update")`. Updates title or is_completed.
- `DELETE /api/teams/[teamId]/boards/[boardId]/cards/[cardId]/checklist/[itemId]`
— uses `authUser(event, "cards.update")`.
UI:
Create `app/pages/boards/[boardId].vue` — the kanban board view, wrapped
in a `UDashboardPanel`:
- `UDashboardNavbar` in the header: board name as title (fetched from the
board endpoint), `UDashboardSidebarCollapse` on the left. On the right,
a "New Card" button wrapped in `CanAccess permission="cards.create"`,
and a board settings gear icon.
- The board body is a horizontal scrollable container with columns
side-by-side. Each column is a vertical container with:
- A header showing the column name, card count (and card limit if set,
e.g. "3 / 5"), and an "Add Card" button.
- A scrollable list of card components below the header.
- If the column is at its card_limit, the header shows a warning color.
- Each card component shows: title, priority badge (small colored dot),
assignee avatar or initials, due date (with red text if overdue), label
badges, and a checklist progress indicator (e.g. "2/5") if the card has
checklist items.
- **Drag and drop**: cards can be dragged between columns and reordered
within a column. Install and use `vuedraggable` (or `@vueuse/integrations`
with sortable). On drop, call the `/cards/move` endpoint with the new
column and position. Update the UI optimistically — show the card in its
new position immediately, then roll back if the API call fails.
- Clicking a card opens a `USlideover` for the card detail. Show all fields
in an editable form at the top: title, description (textarea), column
(select to move between columns), assignee (select from team members,
use placeholder "Unassigned"), priority (select), due date (`UPopover`
with `UCalendar`), labels (comma-separated input).
Below the form, show three sections:
1. "Checklist" — a list of checklist items with checkboxes. Completed
items are struck through. An "Add Item" input at the bottom. Delete
button (x) on each item.
2. "Comments" — a scrollable list of comments (author, content, timestamp)
with a text input at the bottom to add a new comment.
3. Actions — archive card toggle, delete button wrapped in
`CanAccess permission="cards.delete"` with confirmation.
- "New Card" (either the top button or column-level button) opens a `UModal`
with a quick-create form: title (required), column (pre-selected if added
from a column button), assignee, priority, due date. Use Zod for
validation.
- After any card change, refresh the board.Dashboard and Realtime
Prompt 1 — Dashboard
Replace the placeholder dashboard with real board and task stats.
Server route:
Create `GET /api/teams/[teamId]/stats` — uses `authUser(event, "team.view")`.
Returns a JSON object with:
- `board_count` — total number of non-archived boards for the team
- `total_cards` — total number of non-archived cards across all boards
- `overdue_cards` — count of non-archived cards with due_date < today
- `completed_this_week` — count of cards that were moved to a column named
"Done" (case-insensitive) in the current calendar week, based on
updated_at
- `cards_by_priority` — array of { priority, count } for non-archived cards
- `cards_by_board` — array of { board_name, board_color, card_count } for
each non-archived board, ordered by card_count desc
- `my_cards` — up to 10 non-archived cards assigned to the requesting user,
ordered by due_date asc (nulls last), with board name and column name
- `overdue_list` — up to 10 overdue non-archived cards with board name,
column name, assignee name, title, and due_date
All queries are scoped to the team's team_id.
UI:
Update `app/pages/dashboard/index.vue` to show:
- A row of four stat cards at the top using a grid layout. Each card shows
an icon, a label, and the value. Cards: "Active Boards" (board_count),
"Total Cards" (total_cards), "Overdue" (overdue_cards, red text if > 0),
"Completed This Week" (completed_this_week).
Use `USkeleton` placeholders while loading.
- Below the stats, a two-column layout:
- Left column: "My Cards" — a list of cards assigned to the current user.
Each item shows card title, board name (with color dot), column name,
priority badge, and due date (red if overdue). Clicking a card navigates
to the board. Show an empty state if no assigned cards.
- Right column: "Overdue" — a list of overdue cards. Each item shows
title, board name, assignee name, and how many days overdue. Show an
empty state if nothing is overdue.
Remove any placeholder/scaffolding content that was in the dashboard before.
Keep the `UDashboardPanel` wrapper with navbar and sidebar collapse.Prompt 2 — Realtime
Add Supabase Realtime sync for the new tables so changes appear instantly
across browser sessions.
Database migration (via Supabase MCP):
Enable realtime publication and full replica identity for the new tables:
```sql
ALTER PUBLICATION supabase_realtime ADD TABLE boards, columns, cards, card_comments, card_checklist_items;
ALTER TABLE boards REPLICA IDENTITY FULL;
ALTER TABLE columns REPLICA IDENTITY FULL;
ALTER TABLE cards REPLICA IDENTITY FULL;
ALTER TABLE card_comments REPLICA IDENTITY FULL;
ALTER TABLE card_checklist_items REPLICA IDENTITY FULL;
```
Composables:
1. Create `app/composables/useRealtimeCards.ts` — takes a
`Ref<Card[]>` (or whatever the card array type is). Registers on
the `cards` table via the existing `useRealtime` composable's
`onTable` function. Handles INSERT (dedup against existing items by id),
UPDATE (replace the matching item), and DELETE (remove by id). Always
assign a new array to `.value` — do not mutate in place, or Vue will
not re-render.
2. Create `app/composables/useRealtimeColumns.ts` — same pattern but for
the `columns` table. Used in the board view to detect new columns or
reorder changes.
Update `app/composables/useRealtime.ts` to subscribe to `boards`, `columns`,
`cards`, `card_comments`, and `card_checklist_items` tables in addition to
the existing subscriptions.
Integration:
- In `app/pages/boards/[boardId].vue`, call `useRealtimeCards(cards)` and
`useRealtimeColumns(columns)` to keep the board view in sync when
another user moves or creates cards.
- In `app/pages/boards/index.vue`, refetch the board list when any realtime
event fires on `boards`. Use `onTable("boards", refetch)` directly.
- The dashboard stats page should refetch stats when any realtime event
fires on cards or boards. Use `onTable("cards", refetchStats)` and
`onTable("boards", refetchStats)` directly in the dashboard page.What You Built
Starting from a template that handled auth, teams, roles, and permissions, you added:
- Boards — multiple project boards with color-coding and archiving
- Columns — customizable pipeline stages with card limits and drag-and-drop reordering
- Cards — full task cards with assignees, priorities, due dates, labels, checklists, and comments
- Drag and drop — cards move between columns with optimistic updates and API persistence
- Dashboard — personal task list, overdue tracking, and team workload
- Realtime — all changes sync instantly across connected browsers
Every feature follows the same patterns: permission-gated server routes, team-scoped data, Nuxt UI components, and the conventions defined in CLAUDE.md.
What's Next
- API Keys & External API — let external tools create cards or update statuses programmatically
- AI Chat — a chatbot that answers questions like "What cards are overdue?" or "How many tasks did we complete this week?"