BTST

Kanban Plugin

Project management with boards, columns, tasks, drag-and-drop, and priority levels

Kanban Plugin Demo - Board View

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

View interactive demo →

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:

lib/stack.ts
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:

lib/stack-client.tsx
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 prefetching
  • apiBasePath: Path where your API is mounted (e.g., /api/data)
  • siteBaseURL: Base URL of your site
  • siteBasePath: 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:

app/globals.css
@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:

app/pages/[[...all]]/layout.tsx
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>
  )
}
app/routes/pages/_layout.tsx
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>
  )
}
src/routes/pages/route.tsx
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 calls
  • apiBasePath: Path where your API is mounted
  • navigate: Function for programmatic navigation
  • resolveUser: 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 cache
  • localization: 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.prisma

This 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:

PriorityBadge ColorUse Case
LOWGrayNice-to-have tasks
MEDIUMYellowStandard priority (default)
HIGHOrangeImportant tasks
URGENTRedCritical 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:

Context 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

Clerk Integration
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,
      }))
    },
  }
}
NextAuth/Prisma Integration
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,
      }))
    },
  }
}
Mock Users (for development)
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:

lib/stack.ts
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:

HookDescription
onBeforeListBoardsCalled before listing boards. Throw to deny.
onBeforeCreateBoardCalled before creating a board. Throw to deny.
onBeforeReadBoardCalled before reading a single board. Throw to deny.
onBeforeUpdateBoardCalled before updating a board. Throw to deny.
onBeforeDeleteBoardCalled before deleting a board. Throw to deny.
onBeforeCreateColumnCalled before creating a column. Throw to deny.
onBeforeUpdateColumnCalled before updating a column. Throw to deny.
onBeforeDeleteColumnCalled before deleting a column. Throw to deny.
onBeforeCreateTaskCalled before creating a task. Throw to deny.
onBeforeUpdateTaskCalled before updating a task. Throw to deny.
onBeforeDeleteTaskCalled before deleting a task. Throw to deny.
onBoardsReadCalled after boards are listed successfully.
onBoardCreatedCalled after a board is created.
onBoardUpdatedCalled after a board is updated.
onBoardDeletedCalled after a board is deleted.
onColumnCreatedCalled after a column is created.
onColumnUpdatedCalled after a column is updated.
onColumnDeletedCalled after a column is deleted.
onTaskCreatedCalled after a task is created.
onTaskUpdatedCalled after a task is updated.
onTaskDeletedCalled after a task is deleted.

Client (@btst/stack/plugins/kanban/client)

kanbanClientPlugin

Creates the kanban client plugin with routes, loaders, and meta generators.

lib/stack-client.tsx
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:

HookDescription
beforeLoadBoardsCalled before loading boards list. Throw to cancel.
afterLoadBoardsCalled after boards are loaded.
beforeLoadBoardCalled before loading a single board. Throw to cancel.
afterLoadBoardCalled after a board is loaded.
beforeLoadNewBoardCalled before loading the new board page. Throw to cancel.
afterLoadNewBoardCalled after the new board page is loaded.
onLoadErrorCalled 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:

OverrideTypeDescription
apiBaseURLstringBase URL for API calls
apiBasePathstringPath where your API is mounted
navigate(path: string) => voidFunction for programmatic navigation
resolveUser(userId: string) => KanbanUser | nullResolve user info from ID
searchUsers(query: string, boardId?: string) => KanbanUser[]Search/list users for picker

Slot overrides:

OverrideTypeDescription
taskDetailBottomSlot(task: SerializedTask) => ReactNodeRender 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

app/lib/stack.ts
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

FunctionReturnsDescription
getAllBoards(adapter, params?)BoardListResultPaginated boards with columns and tasks; supports slug/ownerId/organizationId filters
getBoardById(adapter, id)BoardWithColumns | nullSingle 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 keyParams requiredData prefetched
"boards"First page of boards
"newBoard"(nothing)
"board"{ boardId: string }Single board with columns and tasks

Next.js example

app/pages/kanban/page.tsx
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:

lib/stack.ts
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

FunctionDescription
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:

lib/stack.ts
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.adapter

Shadcn 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.json
pnpx shadcn@latest add https://github.com/better-stack-ai/better-stack/blob/main/packages/stack/registry/btst-kanban.json
bunx shadcn@latest add https://github.com/better-stack-ai/better-stack/blob/main/packages/stack/registry/btst-kanban.json

This 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:

lib/stack-client.tsx
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.