Kanban Plugin
Project management with boards, columns, tasks, drag-and-drop, and priority levels
Overview
The Kanban plugin provides a full-featured project management system with:
- Boards - Organize different projects or workflows
- Columns - Define workflow stages (e.g., To Do, In Progress, Done)
- Tasks - Track individual work items with priorities
- Assignees - Assign users to tasks with avatar display
- Drag-and-Drop - Reorder tasks and columns with smooth animations
- Priority Levels - LOW, MEDIUM, HIGH, URGENT with visual badges
- Organization Support - Optional scoping by user or organization
Installation
Ensure you followed the general framework installation guide first.
Follow these steps to add the Kanban plugin to your BTST setup.
1. Add Plugin to Backend API
Import and register the kanban backend plugin in your stack.ts file:
import { stack } from "@btst/stack"
import { kanbanBackendPlugin } from "@btst/stack/plugins/kanban/api"
// ... your adapter imports
const { handler, dbSchema } = stack({
basePath: "/api/data",
plugins: {
kanban: kanbanBackendPlugin()
},
adapter: (db) => createPrismaAdapter(prisma, db, {
provider: "postgresql"
})
})
export { handler, dbSchema }The kanbanBackendPlugin() accepts optional hooks for customizing behavior (authorization, logging, etc.).
2. Add Plugin to Client
Register the kanban client plugin in your stack-client.tsx file:
import { createStackClient } from "@btst/stack/client"
import { kanbanClientPlugin } from "@btst/stack/plugins/kanban/client"
import { QueryClient } from "@tanstack/react-query"
const getBaseURL = () =>
(process.env.BASE_URL || "http://localhost:3000")
export const getStackClient = (queryClient: QueryClient) => {
const baseURL = getBaseURL()
return createStackClient({
plugins: {
kanban: kanbanClientPlugin({
// Required configuration
apiBaseURL: baseURL,
apiBasePath: "/api/data",
siteBaseURL: baseURL,
siteBasePath: "/pages",
queryClient: queryClient,
// Optional: SEO configuration
seo: {
siteName: "My Kanban App",
description: "Manage your projects with kanban boards",
},
})
}
})
}Required configuration:
apiBaseURL: Base URL for API calls during SSR data prefetchingapiBasePath: Path where your API is mounted (e.g.,/api/data)siteBaseURL: Base URL of your sitesiteBasePath: Path where your pages are mounted (e.g.,/pages)queryClient: React Query client instance
3. Import Plugin CSS
Add the kanban plugin CSS to your global stylesheet:
@import "@btst/stack/plugins/kanban/css";This includes all necessary styles for the kanban board components and drag-and-drop functionality.
4. Add Context Overrides
Configure framework-specific overrides in your StackProvider:
import { StackProvider } from "@btst/stack/context"
import type { KanbanPluginOverrides } from "@btst/stack/plugins/kanban/client"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { resolveUser, searchUsers } from "@/lib/users" // Your user resolver
const getBaseURL = () =>
typeof window !== 'undefined'
? (process.env.NEXT_PUBLIC_BASE_URL || window.location.origin)
: (process.env.BASE_URL || "http://localhost:3000")
type PluginOverrides = {
kanban: KanbanPluginOverrides
}
export default function Layout({ children }) {
const router = useRouter()
const baseURL = getBaseURL()
return (
<StackProvider<PluginOverrides>
basePath="/pages"
overrides={{
kanban: {
apiBaseURL: baseURL,
apiBasePath: "/api/data",
navigate: (path) => router.push(path),
refresh: () => router.refresh(),
Link: (props) => <Link {...props} />,
// Required: User resolution for assignees
resolveUser,
searchUsers,
}
}}
>
{children}
</StackProvider>
)
}import { Outlet, Link, useNavigate } from "react-router"
import { StackProvider } from "@btst/stack/context"
import type { KanbanPluginOverrides } from "@btst/stack/plugins/kanban/client"
import { resolveUser, searchUsers } from "../lib/users" // Your user resolver
const getBaseURL = () =>
typeof window !== 'undefined'
? (import.meta.env.VITE_BASE_URL || window.location.origin)
: (process.env.BASE_URL || "http://localhost:5173")
type PluginOverrides = {
kanban: KanbanPluginOverrides
}
export default function Layout() {
const navigate = useNavigate()
const baseURL = getBaseURL()
return (
<StackProvider<PluginOverrides>
basePath="/pages"
overrides={{
kanban: {
apiBaseURL: baseURL,
apiBasePath: "/api/data",
navigate: (href) => navigate(href),
Link: ({ href, children, className, ...props }) => (
<Link to={href || ""} className={className} {...props}>
{children}
</Link>
),
// Required: User resolution for assignees
resolveUser,
searchUsers,
}
}}
>
<Outlet />
</StackProvider>
)
}import { StackProvider } from "@btst/stack/context"
import type { KanbanPluginOverrides } from "@btst/stack/plugins/kanban/client"
import { Link, useRouter, Outlet } from "@tanstack/react-router"
import { resolveUser, searchUsers } from "../../lib/users" // Your user resolver
const getBaseURL = () =>
typeof window !== 'undefined'
? (import.meta.env.VITE_BASE_URL || window.location.origin)
: (process.env.BASE_URL || "http://localhost:3000")
type PluginOverrides = {
kanban: KanbanPluginOverrides
}
function Layout() {
const router = useRouter()
const baseURL = getBaseURL()
return (
<StackProvider<PluginOverrides>
basePath="/pages"
overrides={{
kanban: {
apiBaseURL: baseURL,
apiBasePath: "/api/data",
navigate: (href) => router.navigate({ href }),
Link: ({ href, children, className, ...props }) => (
<Link to={href} className={className} {...props}>
{children}
</Link>
),
// Required: User resolution for assignees
resolveUser,
searchUsers,
}
}}
>
<Outlet />
</StackProvider>
)
}Required overrides:
apiBaseURL: Base URL for API callsapiBasePath: Path where your API is mountednavigate: Function for programmatic navigationresolveUser: Function to resolve user info from ID (for assignee display)searchUsers: Function to search/list users (for assignee picker)
Optional overrides:
Link: Custom Link component (defaults to<a>tag)refresh: Function to refresh server-side cachelocalization: Custom localization strings
5. Generate Database Schema
After adding the plugin, generate your database schema using the CLI:
npx @btst/cli generate --orm prisma --config lib/stack.ts --output prisma/schema.prismaThis will create the necessary database tables for boards, columns, and tasks. Run migrations as needed for your ORM.
Congratulations, You're Done!
Your kanban plugin is now fully configured and ready to use! Here's a quick reference of what's available:
API Endpoints
The kanban plugin provides the following API endpoints (mounted at your configured apiBasePath):
Boards:
- GET
/boards- List boards with optional filtering - GET
/boards/:id- Get a single board with columns and tasks - POST
/boards- Create a new board (with default columns) - PUT
/boards/:id- Update a board - DELETE
/boards/:id- Delete a board
Columns:
- POST
/columns- Create a new column - PUT
/columns/:id- Update a column - DELETE
/columns/:id- Delete a column - POST
/columns/reorder- Reorder columns within a board
Tasks:
- POST
/tasks- Create a new task - PUT
/tasks/:id- Update a task - DELETE
/tasks/:id- Delete a task - POST
/tasks/move- Move a task to a different column - POST
/tasks/reorder- Reorder tasks within a column
Page Routes
The kanban plugin automatically creates the following pages (mounted at your configured siteBasePath):
/kanban- Boards list page/kanban/new- Create new board page/kanban/:boardId- Board detail page with kanban view
Page Component Overrides
You can replace any built-in page with your own React component using the optional pageComponents field in kanbanClientPlugin(config). The built-in component is used as the fallback whenever an override is not provided, so this is fully backward-compatible.
kanbanClientPlugin({
// ... other config
pageComponents: {
// Replace the boards list page
boards: MyCustomBoardsPage,
// Replace the board detail page — receives boardId as a prop
board: ({ boardId }) => <MyCustomBoardPage boardId={boardId} />,
// Replace the new board page
newBoard: MyCustomNewBoardPage,
},
})Priority Levels
Tasks support four priority levels, each with a visual badge:
| Priority | Badge Color | Use Case |
|---|---|---|
| LOW | Gray | Nice-to-have tasks |
| MEDIUM | Yellow | Standard priority (default) |
| HIGH | Orange | Important tasks |
| URGENT | Red | Critical tasks requiring immediate attention |
Task Assignees
The kanban plugin supports assigning users to tasks. Since the plugin is authentication-agnostic, you provide resolver functions to integrate with your auth system.
KanbanUser Type
The plugin uses a simple KanbanUser interface for user information:
interface KanbanUser {
id: string; // Unique user identifier
name: string; // Display name
avatarUrl?: string; // Optional avatar image URL
email?: string; // Optional email address
}Required Resolver Functions
You must provide two resolver functions in your overrides:
overrides={{
kanban: {
// ... other overrides
// Resolve user info from an ID (for displaying assignee on task cards)
resolveUser: (userId: string) => {
// Return KanbanUser or null if not found
},
// Search/list users (for the assignee picker dropdown)
searchUsers: (query: string, boardId?: string) => {
// Return array of KanbanUser matching the query
// Return all users if query is empty
},
}
}}Integration Examples
import { clerkClient } from "@clerk/nextjs/server"
const overrides = {
kanban: {
// ... other overrides
resolveUser: async (userId) => {
const user = await clerkClient.users.getUser(userId)
return {
id: user.id,
name: user.fullName || user.username || "Unknown",
avatarUrl: user.imageUrl,
email: user.emailAddresses[0]?.emailAddress,
}
},
searchUsers: async (query) => {
const users = await clerkClient.users.getUserList({ query, limit: 10 })
return users.map(user => ({
id: user.id,
name: user.fullName || user.username || "Unknown",
avatarUrl: user.imageUrl,
email: user.emailAddresses[0]?.emailAddress,
}))
},
}
}import { prisma } from "@/lib/prisma"
const overrides = {
kanban: {
// ... other overrides
resolveUser: async (userId) => {
const user = await prisma.user.findUnique({
where: { id: userId }
})
return user ? {
id: user.id,
name: user.name || "Unknown",
avatarUrl: user.image || undefined,
email: user.email || undefined,
} : null
},
searchUsers: async (query) => {
const users = await prisma.user.findMany({
where: query ? {
OR: [
{ name: { contains: query, mode: "insensitive" } },
{ email: { contains: query, mode: "insensitive" } },
]
} : undefined,
take: 10,
})
return users.map(user => ({
id: user.id,
name: user.name || "Unknown",
avatarUrl: user.image || undefined,
email: user.email || undefined,
}))
},
}
}import type { KanbanUser } from "@btst/stack/plugins/kanban/client"
const MOCK_USERS: KanbanUser[] = [
{ id: "user-1", name: "Alice Johnson", avatarUrl: "https://api.dicebear.com/7.x/avataaars/svg?seed=alice" },
{ id: "user-2", name: "Bob Smith", avatarUrl: "https://api.dicebear.com/7.x/avataaars/svg?seed=bob" },
{ id: "user-3", name: "Carol Williams", avatarUrl: "https://api.dicebear.com/7.x/avataaars/svg?seed=carol" },
]
const overrides = {
kanban: {
// ... other overrides
resolveUser: (userId) => MOCK_USERS.find(u => u.id === userId) ?? null,
searchUsers: (query) => {
if (!query) return MOCK_USERS
const lower = query.toLowerCase()
return MOCK_USERS.filter(u => u.name.toLowerCase().includes(lower))
},
}
}Assignee Display
When a task has an assignee:
- Task Card: Shows the user's avatar and name
- Task Form: Displays a searchable dropdown to select/change assignee
When no assignee is set, the task card shows "Unassigned" with a placeholder icon.
API Reference
Backend (@btst/stack/plugins/kanban/api)
kanbanBackendPlugin
Creates the kanban backend plugin with optional hooks for authorization and customization.
import { kanbanBackendPlugin } from "@btst/stack/plugins/kanban/api"
const { handler, dbSchema } = stack({
plugins: {
kanban: kanbanBackendPlugin(hooks)
},
// ...
})KanbanBackendHooks
Customize backend behavior with optional lifecycle hooks. All hooks are optional and allow you to add authorization, logging, and custom behavior:
import { kanbanBackendPlugin, type KanbanBackendHooks } from "@btst/stack/plugins/kanban/api"
const kanbanHooks: KanbanBackendHooks = {
// Board hooks — throw to deny access
onBeforeListBoards: async (filter, context) => {
if (!await isAuthenticated(context.headers))
throw new Error("Unauthorized")
},
onBeforeCreateBoard: async (data, context) => {
if (!await isAuthenticated(context.headers))
throw new Error("Unauthorized")
},
onBeforeUpdateBoard: async (boardId, data, context) => {
if (!await isBoardOwner(boardId, context.headers))
throw new Error("You do not own this board")
},
onBeforeDeleteBoard: async (boardId, context) => {
if (!await isBoardOwner(boardId, context.headers))
throw new Error("You do not own this board")
},
// Column hooks
onBeforeCreateColumn: async (data, context) => {
if (!await canEditBoard(data.boardId, context.headers))
throw new Error("Unauthorized")
},
onBeforeUpdateColumn: async (columnId, data, context) => {
if (!await canEditColumn(columnId, context.headers))
throw new Error("Unauthorized")
},
onBeforeDeleteColumn: async (columnId, context) => {
if (!await canEditColumn(columnId, context.headers))
throw new Error("Unauthorized")
},
// Task hooks
onBeforeCreateTask: async (data, context) => {
if (!await canEditColumn(data.columnId, context.headers))
throw new Error("Unauthorized")
},
onBeforeUpdateTask: async (taskId, data, context) => {
if (!await canEditTask(taskId, context.headers))
throw new Error("Unauthorized")
},
onBeforeDeleteTask: async (taskId, context) => {
if (!await canEditTask(taskId, context.headers))
throw new Error("Unauthorized")
},
// Lifecycle hooks
onBoardCreated: async (board, context) => {
console.log("Board created:", board.name)
},
onTaskCreated: async (task, context) => {
console.log("Task created:", task.title)
},
}
const { handler, dbSchema } = stack({
plugins: {
kanban: kanbanBackendPlugin(kanbanHooks)
},
// ...
})Available hooks:
| Hook | Description |
|---|---|
onBeforeListBoards | Called before listing boards. Throw to deny. |
onBeforeCreateBoard | Called before creating a board. Throw to deny. |
onBeforeReadBoard | Called before reading a single board. Throw to deny. |
onBeforeUpdateBoard | Called before updating a board. Throw to deny. |
onBeforeDeleteBoard | Called before deleting a board. Throw to deny. |
onBeforeCreateColumn | Called before creating a column. Throw to deny. |
onBeforeUpdateColumn | Called before updating a column. Throw to deny. |
onBeforeDeleteColumn | Called before deleting a column. Throw to deny. |
onBeforeCreateTask | Called before creating a task. Throw to deny. |
onBeforeUpdateTask | Called before updating a task. Throw to deny. |
onBeforeDeleteTask | Called before deleting a task. Throw to deny. |
onBoardsRead | Called after boards are listed successfully. |
onBoardCreated | Called after a board is created. |
onBoardUpdated | Called after a board is updated. |
onBoardDeleted | Called after a board is deleted. |
onColumnCreated | Called after a column is created. |
onColumnUpdated | Called after a column is updated. |
onColumnDeleted | Called after a column is deleted. |
onTaskCreated | Called after a task is created. |
onTaskUpdated | Called after a task is updated. |
onTaskDeleted | Called after a task is deleted. |
Client (@btst/stack/plugins/kanban/client)
kanbanClientPlugin
Creates the kanban client plugin with routes, loaders, and meta generators.
kanban: kanbanClientPlugin({
// Required configuration
apiBaseURL: baseURL,
apiBasePath: "/api/data",
siteBaseURL: baseURL,
siteBasePath: "/pages",
queryClient: queryClient,
// Optional SEO configuration
seo: {
siteName: "My Kanban App",
description: "Project management",
},
// Optional hooks
hooks: {
beforeLoadBoards: async (context) => {
if (!await isAuthenticated(context.headers))
throw new Error("Unauthorized")
},
beforeLoadBoard: async (boardId, context) => {
if (!await canViewBoard(boardId, context.headers))
throw new Error("Unauthorized")
},
},
})KanbanClientHooks
Customize client-side behavior with lifecycle hooks:
| Hook | Description |
|---|---|
beforeLoadBoards | Called before loading boards list. Throw to cancel. |
afterLoadBoards | Called after boards are loaded. |
beforeLoadBoard | Called before loading a single board. Throw to cancel. |
afterLoadBoard | Called after a board is loaded. |
beforeLoadNewBoard | Called before loading the new board page. Throw to cancel. |
afterLoadNewBoard | Called after the new board page is loaded. |
onLoadError | Called when a loading error occurs. |
KanbanPluginOverrides
Configure framework-specific overrides and route lifecycle hooks:
overrides={{
kanban: {
// Required overrides
apiBaseURL: baseURL,
apiBasePath: "/api/data",
navigate: (path) => router.push(path),
// Required: User resolution for assignees
resolveUser: (userId) => findUserById(userId),
searchUsers: (query) => searchAllUsers(query),
// Optional overrides
Link: (props) => <Link {...props} />,
refresh: () => router.refresh(),
// Optional lifecycle hooks
onRouteRender: async (routeName, context) => {
console.log("Rendering route:", routeName)
},
onBeforeBoardsPageRendered: (context) => {
return isAuthenticated()
},
onBeforeBoardPageRendered: (boardId, context) => {
return canViewBoard(boardId)
},
onBeforeNewBoardPageRendered: (context) => {
return canCreateBoard()
},
}
}}Required overrides:
| Override | Type | Description |
|---|---|---|
apiBaseURL | string | Base URL for API calls |
apiBasePath | string | Path where your API is mounted |
navigate | (path: string) => void | Function for programmatic navigation |
resolveUser | (userId: string) => KanbanUser | null | Resolve user info from ID |
searchUsers | (query: string, boardId?: string) => KanbanUser[] | Search/list users for picker |
Slot overrides:
| Override | Type | Description |
|---|---|---|
taskDetailBottomSlot | (task: SerializedTask) => ReactNode | Render additional content below task details — use to embed a CommentThread |
import { CommentThread } from "@btst/stack/plugins/comments/client/components"
overrides={{
kanban: {
// ...
taskDetailBottomSlot: (task) => (
<CommentThread
resourceId={task.id}
resourceType="kanban-task"
apiBaseURL={baseURL}
apiBasePath="/api/data"
currentUserId={session?.user?.id}
loginHref="/login"
/>
),
}
}}React Hooks
Import hooks from @btst/stack/plugins/kanban/client/hooks to use in your components:
import {
useBoards,
useBoard,
useBoardMutations,
useColumnMutations,
useTaskMutations,
useResolveUser,
useSearchUsers,
} from "@btst/stack/plugins/kanban/client/hooks"
// List all boards
const { data: boards, isLoading, error } = useBoards()
// Get a single board with columns and tasks
const { data: board, isLoading, error } = useBoard(boardId)
// Board mutations
const { createBoard, updateBoard, deleteBoard, isCreating, isUpdating, isDeleting } = useBoardMutations()
// Column mutations
const { createColumn, updateColumn, deleteColumn } = useColumnMutations()
// Task mutations (includes assigneeId support)
const { createTask, updateTask, deleteTask, moveTask } = useTaskMutations()
// Resolve user info (with caching)
const { data: user, isLoading } = useResolveUser(assigneeId)
// Search users for picker
const { data: users, isLoading } = useSearchUsers(searchQuery, boardId)Types
The plugin exports TypeScript types for all data structures:
// API types
import type {
Board,
Column,
Task,
Priority,
BoardWithColumns,
ColumnWithTasks,
SerializedBoard,
SerializedColumn,
SerializedTask,
} from "@btst/stack/plugins/kanban/api"
// Client types (for user resolution)
import type {
KanbanUser,
KanbanPluginOverrides,
} from "@btst/stack/plugins/kanban/client"Server-side Data Access
The Kanban plugin exposes standalone getter functions for server-side and SSG use cases.
Two patterns
Pattern 1 — via stack().api
import { myStack } from "./stack";
// List all boards (with columns and tasks)
const result = await myStack.api.kanban.getAllBoards({ ownerId: "user-123" });
// result.items — BoardWithColumns[]
// result.total — total count before pagination
// result.limit — applied limit
// result.offset — applied offset
// Get a single board with full column/task tree
const board = await myStack.api.kanban.getBoardById("board-456");
if (board) {
board.columns.forEach((col) => {
console.log(col.title, col.tasks.length, "tasks");
});
}Pattern 2 — direct import
import {
getAllBoards,
getBoardById,
} from "@btst/stack/plugins/kanban/api";
// In Next.js generateStaticParams
export async function generateStaticParams() {
const { items } = await getAllBoards(myAdapter);
return items.map((b) => ({ slug: b.slug }));
}Available getters
| Function | Returns | Description |
|---|---|---|
getAllBoards(adapter, params?) | BoardListResult | Paginated boards with columns and tasks; supports slug/ownerId/organizationId filters |
getBoardById(adapter, id) | BoardWithColumns | null | Single board with full column/task tree, or null |
BoardListResult
Prop
Type
Static Site Generation (SSG)
route.loader() makes HTTP requests to apiBaseURL, which silently fails during next build because no dev server is running. Use prefetchForRoute() instead — it reads directly from the database and pre-populates the React Query cache before rendering.
prefetchForRoute(routeKey, queryClient, params?)
| Route key | Params required | Data prefetched |
|---|---|---|
"boards" | — | First page of boards |
"newBoard" | — | (nothing) |
"board" | { boardId: string } | Single board with columns and tasks |
Next.js example
import { dehydrate, HydrationBoundary } from "@tanstack/react-query"
import { getOrCreateQueryClient } from "@/lib/query-client"
import { getStackClient } from "@/lib/stack-client"
import { myStack } from "@/lib/stack"
import { metaElementsToObject, normalizePath } from "@btst/stack/client"
import type { Metadata } from "next"
export async function generateStaticParams() {
return [{}]
}
// export const revalidate = 3600 // uncomment for ISR
export async function generateMetadata(): Promise<Metadata> {
const queryClient = getOrCreateQueryClient()
const stackClient = getStackClient(queryClient)
const route = stackClient.router.getRoute(normalizePath(["kanban"]))
if (!route) return { title: "Kanban Boards" }
await myStack.api.kanban.prefetchForRoute("boards", queryClient)
return metaElementsToObject(route.meta?.() ?? []) satisfies Metadata
}
export default async function KanbanBoardsPage() {
const queryClient = getOrCreateQueryClient()
const stackClient = getStackClient(queryClient)
const route = stackClient.router.getRoute(normalizePath(["kanban"]))
if (!route) return null
// Reads directly from DB — works at build time, no HTTP server required
await myStack.api.kanban.prefetchForRoute("boards", queryClient)
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<route.PageComponent />
</HydrationBoundary>
)
}ISR cache invalidation
If you use Incremental Static Regeneration, call revalidatePath inside the backend lifecycle hooks so Next.js regenerates the page on the next request:
import { revalidatePath } from "next/cache"
import type { KanbanBackendHooks } from "@btst/stack/plugins/kanban/api"
const kanbanHooks: KanbanBackendHooks = {
onBoardCreated: async (board) => {
revalidatePath("/kanban")
},
onBoardUpdated: async (board) => {
revalidatePath("/kanban")
},
onBoardDeleted: async (boardId) => {
revalidatePath("/kanban")
},
}Query key consistency
prefetchForRoute uses the same query key shapes as createKanbanQueryKeys (the HTTP client). The shared constants live in @btst/stack/plugins/kanban/api as KANBAN_QUERY_KEYS and boardsListDiscriminator, so the two paths can never drift silently.
Server-side Mutations
In addition to the read-only getters, the Kanban plugin exposes mutation functions for creating boards, columns, and tasks directly from server-side code — no HTTP roundtrip required. These live in api/mutations.ts and are re-exported from @btst/stack/plugins/kanban/api.
Mutation functions bypass authorization hooks. Hooks such as onBeforeCreateBoard and onBeforeCreateTask are not called when you use these functions. They are pure database writes — the caller is responsible for any access-control checks before invoking them. Never call mutations from user-facing request handlers without adding your own authorization logic first.
Available mutations
| Function | Description |
|---|---|
createKanbanTask(adapter, input) | Create a task in a column; auto-computes insertion order |
findOrCreateKanbanBoard(adapter, slug, name, columns) | Find a board by slug or create it with custom column titles |
getKanbanColumnsByBoardId(adapter, boardId) | List columns for a board sorted by order |
createKanbanTask
import { createKanbanTask } from "@btst/stack/plugins/kanban/api"
await createKanbanTask(myStack.adapter, {
title: "Review Q1 financials",
columnId: "col-abc",
description: "See attached report",
priority: "HIGH", // "LOW" | "MEDIUM" | "HIGH" | "URGENT"
})findOrCreateKanbanBoard
Creates a board with custom column titles on first call; returns the existing board on subsequent calls. Safe to call concurrently — uses find-before-create.
import { findOrCreateKanbanBoard, getKanbanColumnsByBoardId } from "@btst/stack/plugins/kanban/api"
const board = await findOrCreateKanbanBoard(
myStack.adapter,
"advisor-review-queue", // slug (URL-safe, unique)
"Advisor Review Queue", // display name (used only on creation)
["New Intakes", "Under Review", "Escalated"], // column titles (used only on creation)
)
const columns = await getKanbanColumnsByBoardId(myStack.adapter, board.id)
const target = columns.find((c) => c.title === "New Intakes")Via myStack.api.kanban
All three functions are also available pre-bound to the stack adapter:
const board = await myStack.api.kanban.findOrCreateBoard(
"advisor-review-queue",
"Advisor Review Queue",
["New Intakes", "Under Review", "Escalated"],
)
const columns = await myStack.api.kanban.getColumnsByBoardId(board.id)
await myStack.api.kanban.createTask({
title: "Sarah Chen — Ready for Review",
columnId: columns[0]!.id,
priority: "MEDIUM",
})Inside an AI tool execute function
The primary use case for mutations is populating Kanban boards from server-side AI tool callbacks:
import { tool } from "ai"
import { z } from "zod"
import { createKanbanTask, findOrCreateKanbanBoard, getKanbanColumnsByBoardId } from "@btst/stack/plugins/kanban/api"
// Adapter captured after stack() returns — safe for tool execute closures
let adapter: any
const myTool = tool({
description: "Create a review card",
inputSchema: z.object({ title: z.string(), urgent: z.boolean() }),
execute: async ({ title, urgent }) => {
const board = await findOrCreateKanbanBoard(adapter, "review", "Review Queue", ["To Do", "Urgent"])
const columns = await getKanbanColumnsByBoardId(adapter, board.id)
const col = urgent ? columns.find((c) => c.title === "Urgent") : columns[0]
await createKanbanTask(adapter, { title, columnId: col!.id, priority: urgent ? "URGENT" : "MEDIUM" })
return { success: true }
},
})
export const myStack = stack({ plugins: { aiChat: aiChatBackendPlugin({ tools: { myTool } }), kanban: kanbanBackendPlugin() } })
adapter = myStack.adapterShadcn Registry
The Kanban plugin UI layer is distributed as a shadcn registry block. Use the registry to eject and fully customize the page components while keeping all data-fetching and API logic from @btst/stack.
The registry installs only the view layer. Hooks and data-fetching continue to come from @btst/stack/plugins/kanban/client/hooks.
npx shadcn@latest add https://github.com/better-stack-ai/better-stack/blob/main/packages/stack/registry/btst-kanban.jsonpnpx shadcn@latest add https://github.com/better-stack-ai/better-stack/blob/main/packages/stack/registry/btst-kanban.jsonbunx shadcn@latest add https://github.com/better-stack-ai/better-stack/blob/main/packages/stack/registry/btst-kanban.jsonThis copies the page components into src/components/btst/kanban/client/ in your project. All relative imports remain valid and you can edit the files freely — the plugin's data layer stays intact.
Using ejected components
After installing, wire your custom components into the plugin via the pageComponents option in your client plugin config:
import { kanbanClientPlugin } from "@btst/stack/plugins/kanban/client"
// Import your ejected (and customized) page components
import { BoardsPageComponent } from "@/components/btst/kanban/client/components/pages/boards-page"
import { BoardPageComponent } from "@/components/btst/kanban/client/components/pages/board-page"
kanbanClientPlugin({
apiBaseURL: "...",
apiBasePath: "/api/data",
queryClient,
pageComponents: {
boards: BoardsPageComponent, // replaces the boards list page
board: BoardPageComponent, // replaces the board detail page
// newBoard — omit to keep built-in default
},
})Any key you omit falls back to the built-in default, so you can override just the pages you want to change.
