Examples
Kanban / To-Do List
Build a project board with boards, columns, cards, drag-and-drop, and checklists
Prerequisite: Complete the Get Started guide first.
To build this feature, copy the first prompt, paste it into Claude Code (or Codex, or any AI coding tool), let it finish, then run the next prompt.
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
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`.
Only create a SELECT policy for team members — follow the
`announcements_team_member_read` pattern in
`supabase/migrations/00001_initial_schema.sql`. Writes go through
service-role server routes, so no insert/update/delete RLS policies are
needed. Permission checks live in server routes via `authUser(event, "key")`.
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"],
```
Also wire the new tables into the baked-in AI chat and activity log per
CLAUDE.md conventions:
- Add `COMMENT ON COLUMN` on every non-obvious column — one short technical
sentence. Mention format or business rules the DB does not enforce.
- `select enable_activity_log('<table>');` for each mutation-bearing table.
- Grant chat read access on each team-scoped table:
```
grant select on <table> to chat_reader;
create policy "<table>_select_chat" on <table>
for select to chat_reader using (team_id = current_chat_team());
```
Skip tables without a `team_id` column (scope them through a parent).
- Register each table in `tablePermissions` in `shared/permissions.ts`
using the permission keys above.
- Add filter entries in `app/components/activity/List.vue` (`tableItems`)
for each new table.
Regenerate the TypeScript types via Supabase MCP and save them to
`shared/types/database.types.ts`.
Adding the public API later? If you plan to add the Public API plugin later, its page will guide you through adding the required permissions. You don't need to add them now.
Boards & Columns
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/app/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/app/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)
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/app/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
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/app/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.
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;
```
Update `app/composables/useRealtime.ts`:
1. Add `"boards"`, `"columns"`, `"cards"`, `"card_comments"`, and
`"card_checklist_items"` to the `RealtimeTable` union type.
2. In the `setup()` function, add `.on("postgres_changes", ...)` handlers
for each new table, filtered by `team_id=eq.${teamId}` where the table
has a team_id column. For `card_comments` and `card_checklist_items`,
subscribe unfiltered since they lack a direct team_id — RLS already
scopes the events.
Integration — use `onTableDebounced` from `useRealtime()` inline in each
page. Do NOT create separate `useRealtimeX` composable files:
- In `app/pages/app/boards/[boardId].vue`:
`const { onTableDebounced } = useRealtime()`
`onTableDebounced(["cards", "columns", "card_comments", "card_checklist_items"], () => refreshBoard())`
- In `app/pages/app/boards/index.vue`:
`const { onTableDebounced } = useRealtime()`
`onTableDebounced("boards", () => refreshBoards())`
- In the dashboard stats page:
`const { onTableDebounced } = useRealtime()`
`onTableDebounced(["cards", "boards"], () => refreshStats())`
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
- Public API — let external tools create cards or update statuses programmatically
- AI Chat — the baked-in assistant is already wired to your new tables via
tablePermissions. Try it with "What cards are overdue?" or "How many tasks did we complete this week?"