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

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

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
  onBeforeListBoards: async (filter, context) => {
    // Return false to deny access
    return isAuthenticated(context.headers)
  },
  onBeforeCreateBoard: async (data, context) => {
    return isAuthenticated(context.headers)
  },
  onBeforeUpdateBoard: async (boardId, data, context) => {
    return isBoardOwner(boardId, context.headers)
  },
  onBeforeDeleteBoard: async (boardId, context) => {
    return isBoardOwner(boardId, context.headers)
  },
  
  // Column hooks
  onBeforeCreateColumn: async (data, context) => {
    return canEditBoard(data.boardId, context.headers)
  },
  onBeforeUpdateColumn: async (columnId, data, context) => {
    return canEditColumn(columnId, context.headers)
  },
  onBeforeDeleteColumn: async (columnId, context) => {
    return canEditColumn(columnId, context.headers)
  },
  
  // Task hooks
  onBeforeCreateTask: async (data, context) => {
    return canEditColumn(data.columnId, context.headers)
  },
  onBeforeUpdateTask: async (taskId, data, context) => {
    return canEditTask(taskId, context.headers)
  },
  onBeforeDeleteTask: async (taskId, context) => {
    return canEditTask(taskId, context.headers)
  },
  
  // 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. Return false to deny.
onBeforeCreateBoardCalled before creating a board. Return false to deny.
onBeforeReadBoardCalled before reading a single board. Return false to deny.
onBeforeUpdateBoardCalled before updating a board. Return false to deny.
onBeforeDeleteBoardCalled before deleting a board. Return false to deny.
onBeforeCreateColumnCalled before creating a column. Return false to deny.
onBeforeUpdateColumnCalled before updating a column. Return false to deny.
onBeforeDeleteColumnCalled before deleting a column. Return false to deny.
onBeforeCreateTaskCalled before creating a task. Return false to deny.
onBeforeUpdateTaskCalled before updating a task. Return false to deny.
onBeforeDeleteTaskCalled before deleting a task. Return false 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) => {
      return isAuthenticated(context.headers)
    },
    beforeLoadBoard: async (boardId, context) => {
      return canViewBoard(boardId, context.headers)
    },
  },
})

KanbanClientHooks

Customize client-side behavior with lifecycle hooks:

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

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.