BTST

Plugin Development

Build your own plugins for BTST

Learn how to create custom plugins for BTST. Plugins extend your application with new features, routes, and API endpoints while maintaining full type safety across backend and frontend.

Overview

A BTST plugin consists of two parts:

  • Backend Plugin - Defines database schema and API endpoints
  • Client Plugin - Provides routes, React components, and hooks

You can create plugins inside your project (like the Todo example) or as a standalone package to publish on npm using the Plugin Starter repository.

Core Concepts

Plugin Architecture

your-plugin/
├── api/
│   ├── backend.ts          # Backend plugin (defineBackendPlugin)
│   ├── getters.ts          # Pure DB read functions — no HTTP context
│   ├── mutations.ts        # Server-side write functions — no hooks, no HTTP context
│   ├── query-key-defs.ts   # Shared query key shapes (prevents SSG/SSR drift)
│   └── serializers.ts      # Convert Date fields to strings for the query cache
├── client/
│   ├── client.tsx      # Client plugin with routes
│   ├── hooks.tsx       # React Query hooks
│   ├── components.tsx  # Page components
│   └── overrides.ts    # Framework adapter types
├── schema.ts           # Database schema definition
└── types.ts            # Shared TypeScript types

Key Imports

Backend Plugin APIs:

import { 
  defineBackendPlugin,  // Create a backend plugin
  createEndpoint,       // Define an API endpoint
  createDbPlugin,       // Define database schema
  type Adapter          // Database adapter type
} from "@btst/stack/plugins/api"

Client Plugin APIs:

import { 
  defineClientPlugin,   // Create a client plugin
  createRoute,          // Define a route
  createApiClient,      // Type-safe API client
  isConnectionError     // Detect build-time "no server" fetch failures
} from "@btst/stack/plugins/client"

Database Schema

Define your database models using createDbPlugin. Each model specifies fields with their types, constraints, and defaults.

import { createDbPlugin } from "@btst/stack/plugins/api"

export const todosSchema = createDbPlugin("todos", {
  todo: {
    modelName: "todo",
    fields: {
      title: {
        type: "string",
        required: true
      },
      completed: {
        type: "boolean",
        defaultValue: false
      },
      createdAt: {
        type: "date",
        defaultValue: () => new Date()
      }
    }
  }
})

Field Types:

  • string - Text values
  • boolean - True/false values
  • number - Numeric values
  • date - Date/time values

Field Options:

  • required - Field must have a value
  • defaultValue - Default value (can be a function)
  • unique - Value must be unique across all records

Complex Schema Example (Blog Plugin)

For plugins with relationships, define multiple models:

export const blogSchema = createDbPlugin("blog", {
  post: {
    modelName: "post",
    fields: {
      title: { type: "string", required: true },
      content: { type: "string", required: true },
      slug: { type: "string", required: true, unique: true },
      published: { type: "boolean", defaultValue: false },
      publishedAt: { type: "date", required: false },
      createdAt: { type: "date", defaultValue: () => new Date() },
      updatedAt: { type: "date", defaultValue: () => new Date() },
    }
  },
  tag: {
    modelName: "tag",
    fields: {
      name: { type: "string", required: true, unique: true },
      slug: { type: "string", required: true, unique: true },
      createdAt: { type: "date", defaultValue: () => new Date() },
    }
  },
  postTag: {
    modelName: "postTag",
    fields: {
      postId: { type: "string", required: true },
      tagId: { type: "string", required: true },
    }
  }
})

Backend Plugin

The backend plugin defines your API endpoints using the database adapter.

Basic Structure

import { type Adapter, defineBackendPlugin, createEndpoint } from "@btst/stack/plugins/api"
import { z } from "zod"
import { todosSchema as dbSchema } from "../schema"
import type { Todo } from "../types"

// Request validation schemas
const createTodoSchema = z.object({
  title: z.string().min(1, "Title is required"),
  completed: z.boolean().optional().default(false)
})

export const todosBackendPlugin = defineBackendPlugin({
  name: "todos",
  dbPlugin: dbSchema,
  
  routes: (adapter: Adapter) => {
    // Define endpoints here
    return { /* endpoints */ } as const
  }
})

// Export the router type for client-side type safety
export type TodosApiRouter = ReturnType<typeof todosBackendPlugin.routes>

Creating Endpoints

Use createEndpoint to define type-safe API routes:

routes: (adapter: Adapter) => {
  // GET /todos - List all todos
  const listTodos = createEndpoint(
    "/todos",
    { method: "GET" },
    async () => {
      const todos = await adapter.findMany<Todo>({
        model: "todo",
        sortBy: { field: "createdAt", direction: "desc" }
      })
      return todos || []
    }
  )

  // POST /todos - Create a todo
  const createTodo = createEndpoint(
    "/todos",
    { method: "POST", body: createTodoSchema },
    async (ctx) => {
      const { title, completed } = ctx.body
      return await adapter.create<Todo>({
        model: "todo",
        data: { title, completed: completed ?? false, createdAt: new Date() }
      })
    }
  )

  // PUT /todos/:id - Update a todo
  const updateTodo = createEndpoint(
    "/todos/:id",
    { method: "PUT", body: updateTodoSchema },
    async (ctx) => {
      const updated = await adapter.update({
        model: "todo",
        where: [{ field: "id", value: ctx.params.id }],
        update: ctx.body
      })
      if (!updated) throw new Error("Todo not found")
      return updated
    }
  )

  // DELETE /todos/:id - Delete a todo
  const deleteTodo = createEndpoint(
    "/todos/:id",
    { method: "DELETE" },
    async (ctx) => {
      await adapter.delete({
        model: "todo",
        where: [{ field: "id", value: ctx.params.id }]
      })
      return { success: true }
    }
  )

  return { listTodos, createTodo, updateTodo, deleteTodo } as const
}

Adapter Operations

The adapter provides these database operations:

MethodDescription
findMany<T>({ model, where?, sortBy?, limit?, offset? })Query multiple records
create<T>({ model, data })Create a new record
update<T>({ model, where, update })Update matching records
delete<T>({ model, where })Delete matching records
transaction(async (tx) => { ... })Run operations in a transaction

Backend Hooks (Authorization & Lifecycle)

For more control, plugins can accept hooks for authorization and lifecycle events:

export interface BlogBackendHooks {
  // Authorization hooks - throw an error to deny access
  onBeforeCreatePost?: (data, context) => Promise<void> | void
  onBeforeUpdatePost?: (postId, data, context) => Promise<void> | void
  onBeforeDeletePost?: (postId, context) => Promise<void> | void
  onBeforeListPosts?: (filter, context) => Promise<void> | void

  // Lifecycle hooks - called after operations
  onPostCreated?: (post, context) => Promise<void> | void
  onPostUpdated?: (post, context) => Promise<void> | void
  onPostDeleted?: (postId, context) => Promise<void> | void
  onPostsRead?: (posts, filter, context) => Promise<void> | void

  // Error hooks
  onCreatePostError?: (error, context) => Promise<void> | void
  onUpdatePostError?: (error, context) => Promise<void> | void
  onDeletePostError?: (error, context) => Promise<void> | void
  onListPostsError?: (error, context) => Promise<void> | void
}

export const blogBackendPlugin = (hooks?: BlogBackendHooks) =>
  defineBackendPlugin({
    name: "blog",
    dbPlugin: dbSchema,
    routes: (adapter: Adapter) => {
      const createPost = createEndpoint("/posts", { method: "POST", body: createPostSchema },
        async (ctx) => {
          // Authorization check — throw to deny, return to allow
          if (hooks?.onBeforeCreatePost) {
            try {
              await hooks.onBeforeCreatePost(ctx.body, { headers: ctx.headers })
            } catch (e) {
              throw ctx.error(403, { message: e instanceof Error ? e.message : "Unauthorized" })
            }
          }
          
          const post = await adapter.create({ model: "post", data: ctx.body })
          
          // Lifecycle callback
          if (hooks?.onPostCreated) {
            await hooks.onPostCreated(post, { headers: ctx.headers })
          }
          
          return post
        }
      )
      // ... more endpoints
    }
  })

Server-side API (Getter Functions)

Plugins can expose a typed api surface that lets server code — Server Components, generateStaticParams, cron jobs, scripts — query the database directly, without going through HTTP.

Getter functions bypass authorization hooks. Plugin hooks such as onBeforeListPosts or onBeforeListForms are not called when you invoke getters via myStack.api.*. These functions are pure database calls — the caller is fully responsible for performing any access-control checks before invoking them. Do not call getters from user-facing request handlers without adding your own authorization logic first.

Add an api factory to defineBackendPlugin. The factory receives the shared adapter and returns an object of async functions:

export const todosBackendPlugin = defineBackendPlugin({
  name: "todos",
  dbPlugin: dbSchema,

  // Expose server-side getters bound to the adapter
  api: (adapter) => ({
    listTodos: () =>
      adapter.findMany<Todo>({ model: "todo", sortBy: { field: "createdAt", direction: "desc" } }),

    getTodoById: (id: string) =>
      adapter.findOne<Todo>({ model: "todo", where: [{ field: "id", value: id, operator: "eq" }] }),
  }),

  routes: (adapter: Adapter) => {
    // ... existing HTTP endpoints
  },
})

After calling stack(), the returned object exposes the combined api namespace — one key per plugin — plus the raw adapter:

import { stack } from "@btst/stack"
import { todosBackendPlugin } from "./plugins/todo/api/backend"

export const myStack = stack({
  basePath: "/api/data",
  plugins: { todos: todosBackendPlugin },
  adapter: (db) => createMemoryAdapter(db)({}),
})

// Fully typed — no HTTP roundtrip
const todos = await myStack.api.todos.listTodos()
const todo  = await myStack.api.todos.getTodoById("abc-123")

// Or use the raw adapter directly
const raw = await myStack.adapter.findMany<Todo>({ model: "todo" })

When to use this pattern:

Use caseApproach
Server Component / RSCmyStack.api.todos.listTodos()
generateStaticParams (Next.js)Import getters directly and pass any adapter
Cron job / scriptmyStack.api.* or direct getter import
HTTP route handlerHTTP endpoint via routes as normal

Tip — direct getter imports for SSG/build-time:

If you need access to data before your stack() instance is available (e.g. at build time with a separate adapter), export the getter functions independently and pass an adapter yourself:

// api/getters.ts
import type { Adapter } from "@btst/stack/plugins/api"
import type { Todo } from "../types"

export async function listTodos(adapter: Adapter) {
  return adapter.findMany<Todo>({ model: "todo" })
}

// api/backend.ts
import { listTodos } from "./getters"

export const todosBackendPlugin = defineBackendPlugin({
  name: "todos",
  dbPlugin: dbSchema,
  api: (adapter) => ({
    listTodos: () => listTodos(adapter),
  }),
  routes: (adapter) => { /* ... */ },
})

// In api/index.ts — re-export for consumers
export { listTodos } from "./getters"

Server-side Mutations (mutations.ts)

Plugins can also expose write operations that bypass the HTTP layer — useful inside AI tool execute callbacks, cron jobs, admin scripts, or any server-side code that needs to create or update records without going through an HTTP endpoint.

Keep mutations in a separate api/mutations.ts file, distinct from the read-only getters.ts. Both are re-exported from api/index.ts and exposed on the api factory.

Mutation functions bypass authorization hooks. Plugin hooks such as onBeforeCreateTask are not called when you use these functions directly. The caller is responsible for any access-control checks before invoking mutations. Never call them from user-facing request handlers without adding your own authorization logic first.

// api/mutations.ts — write operations, no hooks, no HTTP context
import type { Adapter } from "@btst/stack/plugins/api"
import type { Todo } from "../types"

export interface CreateTodoInput {
  title: string
  description?: string
}

/**
 * Create a todo directly in the database.
 *
 * @remarks Authorization hooks are NOT called. The caller is responsible for
 * access-control checks.
 */
export async function createTodo(
  adapter: Adapter,
  input: CreateTodoInput,
): Promise<Todo> {
  return adapter.create<Todo>({
    model: "todo",
    data: {
      title: input.title,
      description: input.description,
      completed: false,
      createdAt: new Date(),
      updatedAt: new Date(),
    },
  })
}

Wire mutations into the api factory alongside the read methods:

// api/backend.ts
import { listTodos } from "./getters"
import { createTodo, type CreateTodoInput } from "./mutations"

export const todosBackendPlugin = defineBackendPlugin({
  name: "todos",
  dbPlugin: dbSchema,
  api: (adapter) => ({
    // Reads
    listTodos: () => listTodos(adapter),
    // Mutations
    createTodo: (input: CreateTodoInput) => createTodo(adapter, input),
  }),
  routes: (adapter) => { /* HTTP endpoints */ },
})

// api/index.ts — re-export both
export { listTodos } from "./getters"
export { createTodo, type CreateTodoInput } from "./mutations"

Common use case — inside an AI tool execute function:

Because AI tool execute functions run at request time (after module initialization), the adapter can safely be captured via a module-level variable set immediately after stack() returns:

lib/stack.ts
import { createTodo } from "./plugins/todo/api/mutations"

// eslint-disable-next-line @typescript-eslint/no-explicit-any
let _adapter: any

const myTool = tool({
  description: "Create a new task",
  inputSchema: z.object({ title: z.string() }),
  execute: async ({ title }) => {
    await createTodo(_adapter, { title })
    return { success: true }
  },
})

export const myStack = stack({
  plugins: {
    todos: todosBackendPlugin,
    aiChat: aiChatBackendPlugin({ tools: { myTool } }),
  },
  adapter: (db) => createMemoryAdapter(db)({}),
})

// Adapter is now available — execute() only fires during HTTP requests,
// which occur after module initialization is complete.
_adapter = myStack.adapter

Client Plugin

The client plugin defines routes with React components, SSR data loaders, and SEO meta generators.

Basic Structure

import { createApiClient, defineClientPlugin, createRoute } from "@btst/stack/plugins/client"
import type { QueryClient } from "@tanstack/react-query"
import type { TodosApiRouter } from "../api/backend"
import { lazy } from "react"

export interface TodosClientConfig {
  queryClient: QueryClient
  apiBaseURL: string
  apiBasePath: string
  siteBaseURL: string
  siteBasePath: string
}

export const todosClientPlugin = (config: TodosClientConfig) =>
  defineClientPlugin({
    name: "todos",
    
    routes: () => ({
      todos: createRoute("/todos", () => {
        const TodosListPage = lazy(() =>
          import("./components").then((m) => ({ default: m.TodosListPage }))
        )
        
        return {
          PageComponent: TodosListPage,
          loader: todosLoader(config),
          meta: createTodosMeta(config, "/todos"),
        }
      }),
    }),
    
    sitemap: async () => [
      { 
        url: `${config.siteBaseURL}${config.siteBasePath}/todos`, 
        lastModified: new Date(), 
        priority: 0.7 
      },
    ],
  })

SSR Data Loaders

Loaders prefetch data during server-side rendering. Always add an isConnectionError check in the catch block so developers get an actionable warning if they call route.loader() during next build when no HTTP server is running (instead of a silent empty page):

import { createApiClient, isConnectionError } from "@btst/stack/plugins/client"

function todosLoader(config: TodosClientConfig) {
  return async () => {
    // Only run on server
    if (typeof window === "undefined") {
      const { queryClient, apiBasePath, apiBaseURL } = config
      
      try {
        await queryClient.prefetchQuery({
          queryKey: ["todos"],
          queryFn: async () => {
            const client = createApiClient<TodosApiRouter>({
              baseURL: apiBaseURL,
              basePath: apiBasePath,
            })
            const response = await client("/todos", { method: "GET" })
            return response.data
          },
        })
      } catch (error) {
        if (isConnectionError(error)) {
          console.warn(
            "[your-plugin] route.loader() failed — no server running at build time. " +
            "Use myStack.api.todos.prefetchForRoute() for SSG data prefetching."
          )
        }
        // Don't re-throw — let Error Boundaries handle it during render
      }
    }
  }
}

SEO Meta Generators

Meta generators create SEO tags based on loaded data:

function createTodosMeta(config: TodosClientConfig, path: string) {
  return () => {
    const { queryClient, siteBaseURL, siteBasePath } = config
    const todos = queryClient.getQueryData<Todo[]>(["todos"]) ?? []
    const fullUrl = `${siteBaseURL}${siteBasePath}${path}`
    
    return [
      { name: "title", content: `${todos.length} Todos` },
      { name: "description", content: `Track ${todos.length} todos.` },
      { property: "og:title", content: `${todos.length} Todos` },
      { property: "og:url", content: fullUrl },
      { name: "twitter:card", content: "summary" },
    ]
  }
}

Static Site Generation (SSG)

route.loader() makes HTTP requests that fail silently during next build because no HTTP server is running. Plugins that support SSG must expose a prefetchForRoute method on the api factory so consumers can seed the query cache directly from the database at build time.

1. Shared query key constants (api/query-key-defs.ts)

Create a file that both query-keys.ts (the HTTP client path) and prefetchForRoute (the DB path) import from. This prevents the two paths drifting out of sync silently:

// api/query-key-defs.ts
export function todosListDiscriminator(params?: { limit?: number }) {
  return { limit: params?.limit ?? 20 }
}

export const TODO_QUERY_KEYS = {
  list: (params?: { limit?: number }) =>
    ["todos", "list", todosListDiscriminator(params)] as const,
  detail: (id: string) => ["todos", "detail", id] as const,
}

Import todosListDiscriminator in query-keys.ts so both paths use the identical key shape.

2. Serializers (api/serializers.ts)

DB getters return Date objects; the HTTP path returns ISO strings. Always serialize before calling setQueryData:

// api/serializers.ts
import type { Todo } from "../types"

export function serializeTodo(todo: Todo) {
  return {
    ...todo,
    createdAt: todo.createdAt.toISOString(),
  }
}

3. RouteKey type and prefetchForRoute overloads (api/backend.ts)

Use typed function overloads so TypeScript enforces the correct params per route:

import type { QueryClient } from "@tanstack/react-query"
import { TODO_QUERY_KEYS } from "./query-key-defs"
import { serializeTodo } from "./serializers"
import { listTodos, getTodoById } from "./getters"

export type TodosRouteKey = "list" | "detail" | "new"

interface TodosPrefetchForRoute {
  (key: "list" | "new", qc: QueryClient): Promise<void>
  (key: "detail", qc: QueryClient, params: { id: string }): Promise<void>
}

function createTodosPrefetchForRoute(adapter: Adapter): TodosPrefetchForRoute {
  return async function prefetchForRoute(
    key: TodosRouteKey,
    qc: QueryClient,
    params?: Record<string, string>,
  ): Promise<void> {
    switch (key) {
      case "list": {
        const todos = await listTodos(adapter)
        // Lists backed by useInfiniteQuery need the { pages, pageParams } shape
        qc.setQueryData(TODO_QUERY_KEYS.list(), {
          pages: [todos.map(serializeTodo)],
          pageParams: [0],
        })
        break
      }
      case "detail": {
        const todo = await getTodoById(adapter, params!.id)
        if (todo) qc.setQueryData(TODO_QUERY_KEYS.detail(params!.id), serializeTodo(todo))
        break
      }
      case "new":
        break // no data needed
    }
  } as TodosPrefetchForRoute
}

export const todosBackendPlugin = defineBackendPlugin({
  name: "todos",
  dbPlugin: dbSchema,
  api: (adapter) => ({
    listTodos: () => listTodos(adapter),
    getTodoById: (id: string) => getTodoById(adapter, id),
    prefetchForRoute: createTodosPrefetchForRoute(adapter), // ← SSG entry point
  }),
  routes: (adapter) => { /* ... HTTP endpoints */ },
})

4. SSG page.tsx (consumer side, Next.js App Router)

The consumer creates a dedicated static page outside [[...all]]/ that calls prefetchForRoute instead of route.loader():

// app/pages/todos/page.tsx
import { dehydrate, HydrationBoundary } from "@tanstack/react-query"
import { notFound } from "next/navigation"
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(["todos"]))
  if (!route) return { title: "Todos" }
  await myStack.api.todos.prefetchForRoute("list", queryClient)
  return metaElementsToObject(route.meta?.() ?? []) satisfies Metadata
}

export default async function TodosPage() {
  const queryClient = getOrCreateQueryClient()
  const stackClient = getStackClient(queryClient)
  const route = stackClient.router.getRoute(normalizePath(["todos"]))
  if (!route) notFound()
  // Direct DB read — no HTTP server required at build time
  await myStack.api.todos.prefetchForRoute("list", queryClient)
  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <route.PageComponent />
    </HydrationBoundary>
  )
}

The shared StackProvider layout must live at app/pages/layout.tsx (not inside [[...all]]/layout.tsx) so it applies to both the catch-all routes and these specific SSG pages.

5. ISR cache invalidation

If you enable Incremental Static Regeneration (export const revalidate = 3600), the cached page must be purged whenever the underlying data changes. Wire up revalidatePath (or revalidateTag) inside the backend plugin hooks:

lib/stack.ts
import { revalidatePath } from "next/cache"

const myPlugin = myBackendPlugin({
  hooks: {
    onAfterCreate: async (item) => {
      revalidatePath("/todos")
    },
    onAfterUpdate: async (item) => {
      revalidatePath("/todos")
    },
    onAfterDelete: async (id) => {
      revalidatePath("/todos")
    },
  },
})

revalidatePath / revalidateTag are Next.js APIs imported from "next/cache". They are no-ops outside of a Next.js runtime, so it is safe to call them from a shared lib/stack.ts without breaking non-Next.js frameworks.


Client Hooks

Type-safe React Query hooks using createApiClient:

"use client"
import { createApiClient } from "@btst/stack/plugins/client"
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query"
import type { TodosApiRouter } from "../api/backend"

export function useTodos() {
  const client = createApiClient<TodosApiRouter>({ baseURL: "/api/data" })

  return useSuspenseQuery({
    queryKey: ["todos"],
    queryFn: async () => {
      const response = await client("/todos", { method: "GET" })
      return response.data
    }
  })
}

export function useCreateTodo() {
  const client = createApiClient<TodosApiRouter>({ baseURL: "/api/data" })
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: async (data: { title: string }) => {
      // Note: @post prefix for POST requests
      const response = await client("@post/todos", {
        method: "POST",
        body: data
      })
      return response.data
    },
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ["todos"] })
    }
  })
}

export function useToggleTodo() {
  const client = createApiClient<TodosApiRouter>({ baseURL: "/api/data" })
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: async (data: { id: string; completed: boolean }) => {
      // Note: @put prefix and params for route parameters
      const response = await client("@put/todos/:id", {
        method: "PUT",
        params: { id: data.id },
        body: { completed: data.completed }
      })
      return response.data
    },
    // Optimistic updates
    onMutate: async (variables) => {
      await queryClient.cancelQueries({ queryKey: ["todos"] })
      const previousTodos = queryClient.getQueryData<Todo[]>(["todos"])
      
      queryClient.setQueryData<Todo[]>(["todos"], (old) =>
        old?.map((todo) =>
          todo.id === variables.id
            ? { ...todo, completed: variables.completed }
            : todo
        )
      )
      
      return { previousTodos }
    },
    onError: (_error, _variables, context) => {
      if (context?.previousTodos) {
        queryClient.setQueryData(["todos"], context.previousTodos)
      }
    },
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ["todos"] })
    }
  })
}

Framework Overrides

Define types for framework-specific components (Link, navigation):

import type { ComponentType, ReactNode } from "react"

export interface TodosPluginOverrides {
  Link: ComponentType<{
    href: string
    children: ReactNode
    className?: string
  }>
  navigate?: (path: string) => void | Promise<void>
}

Use them in components:

import { usePluginOverrides, useBasePath } from "@btst/stack/context"
import type { TodosPluginOverrides } from "./overrides"

function TodosList() {
  const { Link } = usePluginOverrides<TodosPluginOverrides>("todos")
  const basePath = useBasePath()
  
  return (
    <Link href={`${basePath}/todos/add`}>
      Add Todo
    </Link>
  )
}

ComposedRoute

For production-ready page components, use ComposedRoute to wrap your pages with Suspense boundaries, error boundaries, and 404 handling:

import { ComposedRoute } from "@btst/stack/client/components"

Props:

PropTypeDescription
pathstringCurrent route path (used for error boundary reset)
PageComponentReact.ComponentTypeThe page component to render
LoadingComponentReact.ComponentTypeComponent shown during Suspense
ErrorComponentReact.ComponentType<FallbackProps>Error boundary fallback
NotFoundComponentReact.ComponentType<{ message: string }>404 fallback
propsanyProps passed to PageComponent
onError(error: Error, info: ErrorInfo) => voidError callback

Example from Blog Plugin:

"use client"
import { lazy } from "react"
import { ComposedRoute } from "@btst/stack/client/components"
import { usePluginOverrides } from "@btst/stack/context"
import type { BlogPluginOverrides } from "../../overrides"

// Lazy load the page content
const HomePage = lazy(() =>
  import("./home-page.internal").then((m) => ({ default: m.HomePage }))
)

// Loading skeleton component
function PostsLoading() {
  return <div className="animate-pulse">Loading posts...</div>
}

// Error fallback component
function DefaultError({ error, resetErrorBoundary }) {
  return (
    <div>
      <p>Something went wrong: {error.message}</p>
      <button onClick={resetErrorBoundary}>Try again</button>
    </div>
  )
}

// 404 component
function NotFoundPage({ message }) {
  return <div>Page not found: {message}</div>
}

// Exported page component with all boundaries
export function HomePageComponent({ published = true }) {
  const { onRouteError } = usePluginOverrides<BlogPluginOverrides>("blog")
  
  return (
    <ComposedRoute
      path={published ? "/blog" : "/blog/drafts"}
      PageComponent={HomePage}
      LoadingComponent={PostsLoading}
      ErrorComponent={DefaultError}
      NotFoundComponent={NotFoundPage}
      props={{ published }}
      onError={(error) => {
        onRouteError?.("posts", error, {
          path: published ? "/blog" : "/blog/drafts",
          isSSR: typeof window === "undefined",
        })
      }}
    />
  )
}

This pattern ensures:

  • Loading states - Shows a skeleton while lazy components load
  • Error recovery - Catches errors and provides reset functionality
  • 404 handling - Graceful fallback for missing routes
  • Error reporting - Hooks into your error tracking via onError

Plugin Registration

Backend Registration

Register plugins in your BTST configuration:

import { stack } from "@btst/stack"
import { createMemoryAdapter } from "@btst/adapter-memory"
import { todosBackendPlugin } from "./plugins/todo/api/backend"
import { blogBackendPlugin } from "@btst/stack/plugins/blog/api"

// Export the full stack instance so myStack.api.* is accessible anywhere
export const myStack = stack({
  basePath: "/api/data",
  plugins: {
    todos: todosBackendPlugin,
    blog: blogBackendPlugin({
      onBeforeCreatePost: async (data, context) => {
        // Throw to deny, return to allow
        // if (!session) throw new Error("Authentication required")
      },
      onPostCreated: async (post) => {
        console.log("Post created:", post.id)
      }
    })
  },
  adapter: (db) => createMemoryAdapter(db)({})
})

// Named re-exports for the HTTP route handler and DB schema
export const { handler, dbSchema } = myStack

// myStack also exposes:
//   myStack.adapter  — raw database adapter
//   myStack.api      — typed server-side getters per plugin
//
// Usage in a Server Component or generateStaticParams:
//   const todos = await myStack.api.todos.listTodos()
//   const todo  = await myStack.api.todos.getTodoById("abc-123")
//   const posts = await myStack.api.blog.getAllPosts({ published: true })

Client Registration

Register client plugins with your stack client:

import { createStackClient } from "@btst/stack/client"
import { todosClientPlugin } from "./plugins/todo/client/client"
import { blogClientPlugin } from "@btst/stack/plugins/blog/client"
import { QueryClient } from "@tanstack/react-query"

export const getStackClient = (queryClient: QueryClient) => {
  const baseURL = typeof window !== 'undefined' 
    ? window.location.origin 
    : "http://localhost:3000"
    
  return createStackClient({
    plugins: {
      todos: todosClientPlugin({
        queryClient,
        apiBaseURL: baseURL,
        apiBasePath: "/api/data",
        siteBaseURL: baseURL,
        siteBasePath: "/pages",
      }),
      blog: blogClientPlugin({
        queryClient,
        apiBaseURL: baseURL,
        apiBasePath: "/api/data",
        siteBaseURL: baseURL,
        siteBasePath: "/pages",
        seo: {
          siteName: "My Blog",
          author: "Your Name",
          twitterHandle: "@handle",
        },
        hooks: {
          beforeLoadPosts: async (filter, context) => {
            console.log(`Loading ${filter.published ? 'published' : 'drafts'}`)
            // Throw to cancel loading: throw new Error("Not authorised")
          }
        }
      })
    }
  })
}

In-Project Plugin Example

Whether you're building a plugin inside your project or as a standalone npm package, the code and patterns are identical. The only difference is packaging—standalone plugins are built and published to npm, while in-project plugins are imported directly.

Here's a complete example based on the Todo Plugin (found at lib/plugins/todo/ after running bash scripts/codegen/setup-nextjs.sh):

File: lib/plugins/todo/schema.ts

import { createDbPlugin } from "@btst/stack/plugins/api"

export const todosSchema = createDbPlugin("todos", {
  todo: {
    modelName: "todo",
    fields: {
      title: { type: "string", required: true },
      completed: { type: "boolean", defaultValue: false },
      createdAt: { type: "date", defaultValue: () => new Date() }
    }
  }
})

File: lib/plugins/todo/types.ts

export type Todo = {
  id: string
  title: string
  completed: boolean
  createdAt: Date
}

File: lib/plugins/todo/api/getters.ts

import type { Adapter } from "@btst/stack/plugins/api"
import type { Todo } from "../types"

/** Retrieve all todos, sorted newest-first. Safe for server-side and SSG use. */
export async function listTodos(adapter: Adapter): Promise<Todo[]> {
  return adapter.findMany<Todo>({
    model: "todo",
    sortBy: { field: "createdAt", direction: "desc" },
  }) as Promise<Todo[]>
}

/** Retrieve a single todo by ID. Returns null if not found. */
export async function getTodoById(
  adapter: Adapter,
  id: string,
): Promise<Todo | null> {
  return adapter.findOne<Todo>({
    model: "todo",
    where: [{ field: "id", value: id, operator: "eq" }],
  })
}

File: lib/plugins/todo/api/backend.ts

import { type Adapter, defineBackendPlugin, createEndpoint } from "@btst/stack/plugins/api"
import { z } from "zod"
import { todosSchema as dbSchema } from "../schema"
import type { Todo } from "../types"
import { listTodos, getTodoById } from "./getters"

const createTodoSchema = z.object({
  title: z.string().min(1),
  completed: z.boolean().optional().default(false)
})

const updateTodoSchema = z.object({
  title: z.string().min(1).optional(),
  completed: z.boolean().optional()
})

export const todosBackendPlugin = defineBackendPlugin({
  name: "todos",
  dbPlugin: dbSchema,

  // Server-side getters — available as myStack.api.todos.*
  api: (adapter) => ({
    listTodos: () => listTodos(adapter),
    getTodoById: (id: string) => getTodoById(adapter, id),
  }),

  routes: (adapter: Adapter) => {
    const listTodos = createEndpoint("/todos", { method: "GET" },
      async () => adapter.findMany<Todo>({ model: "todo" }) || []
    )
    
    const createTodo = createEndpoint("/todos", { method: "POST", body: createTodoSchema },
      async (ctx) => adapter.create<Todo>({
        model: "todo",
        data: { ...ctx.body, createdAt: new Date() }
      })
    )

    const updateTodo = createEndpoint("/todos/:id", { method: "PUT", body: updateTodoSchema },
      async (ctx) => {
        const updated = await adapter.update({
          model: "todo",
          where: [{ field: "id", value: ctx.params.id }],
          update: ctx.body
        })
        if (!updated) throw new Error("Todo not found")
        return updated
      }
    )

    const deleteTodo = createEndpoint("/todos/:id", { method: "DELETE" },
      async (ctx) => {
        await adapter.delete({ model: "todo", where: [{ field: "id", value: ctx.params.id }] })
        return { success: true }
      }
    )
    
    return { listTodos, createTodo, updateTodo, deleteTodo } as const
  }
})

export type TodosApiRouter = ReturnType<typeof todosBackendPlugin.routes>

File: lib/plugins/todo/client/client.tsx

import { createApiClient, defineClientPlugin, createRoute } from "@btst/stack/plugins/client"
import type { QueryClient } from "@tanstack/react-query"
import type { TodosApiRouter } from "../api/backend"
import { lazy } from "react"
import type { Todo } from "../types"

export interface TodosClientConfig {
  queryClient: QueryClient
  apiBaseURL: string
  apiBasePath: string
  siteBaseURL: string
  siteBasePath: string
  context?: Record<string, unknown>
}

// SSR loader - prefetch data on the server
function todosLoader(config: TodosClientConfig) {
  return async () => {
    if (typeof window === "undefined") {
      const { queryClient, apiBasePath, apiBaseURL } = config
      
      await queryClient.prefetchQuery({
        queryKey: ["todos"],
        queryFn: async () => {
          const client = createApiClient<TodosApiRouter>({
            baseURL: apiBaseURL,
            basePath: apiBasePath,
          })
          const response = await client("/todos", { method: "GET" })
          return response.data
        },
      })
    }
  }
}

// Meta generator - create SEO tags from loaded data
function createTodosMeta(config: TodosClientConfig, path: string) {
  return () => {
    const { queryClient, siteBaseURL, siteBasePath } = config
    const todos = queryClient.getQueryData<Todo[]>(["todos"]) ?? []
    const fullUrl = `${siteBaseURL}${siteBasePath}${path}`
    
    return [
      { name: "title", content: `${todos.length} Todos` },
      { name: "description", content: `Track ${todos.length} todos.` },
      { property: "og:title", content: `${todos.length} Todos` },
      { property: "og:url", content: fullUrl },
      { name: "twitter:card", content: "summary" },
    ]
  }
}

export const todosClientPlugin = (config: TodosClientConfig) =>
  defineClientPlugin({
    name: "todos",

    routes: () => ({
      todos: createRoute("/todos", () => {
        const TodosListPage = lazy(() =>
          import("./components").then((m) => ({ default: m.TodosListPage }))
        )
        
        return {
          PageComponent: TodosListPage,
          loader: todosLoader(config),
          meta: createTodosMeta(config, "/todos"),
        }
      }),
      addTodo: createRoute("/todos/add", () => {
        const AddTodoPage = lazy(() =>
          import("./components").then((m) => ({ default: m.AddTodoPage }))
        )
        
        return {
          PageComponent: AddTodoPage,
          meta: createTodosMeta(config, "/todos/add"),
        }
      }),
    }),
    
    sitemap: async () => [
      { url: `${config.siteBaseURL}${config.siteBasePath}/todos`, lastModified: new Date(), priority: 0.7 },
      { url: `${config.siteBaseURL}${config.siteBasePath}/todos/add`, lastModified: new Date(), priority: 0.6 },
    ],
  })

File: lib/plugins/todo/client/hooks.tsx

"use client"
import { createApiClient } from "@btst/stack/plugins/client"
import { useSuspenseQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import type { TodosApiRouter } from "../api/backend"

export function useTodos() {
  const client = createApiClient<TodosApiRouter>({ baseURL: "/api/data" })
  return useSuspenseQuery({
    queryKey: ["todos"],
    queryFn: async () => (await client("/todos", { method: "GET" })).data
  })
}

export function useCreateTodo() {
  const client = createApiClient<TodosApiRouter>({ baseURL: "/api/data" })
  const queryClient = useQueryClient()
  return useMutation({
    mutationFn: async (title: string) => 
      (await client("@post/todos", { method: "POST", body: { title } })).data,
    onSuccess: () => queryClient.invalidateQueries({ queryKey: ["todos"] })
  })
}

AI Chat Plugin Integration

Plugins can participate in the route-aware AI context system. When a user opens the chat widget while viewing one of your plugin's pages, it can automatically:

  • Inject a description of the current page into the AI's system prompt
  • Expose action chips (quick suggestions) relevant to the page
  • Provide client-side tool handlers the AI can call to mutate page state (fill forms, update editors, etc.)

Step 1 — Register context from the page component

Call useRegisterPageAIContext inside your .internal.tsx page component. The registration is automatically cleaned up on unmount.

import { useRegisterPageAIContext } from "@btst/stack/plugins/ai-chat/client/context"
import { useRef, useCallback } from "react"
import type { UseFormReturn } from "react-hook-form"

export function MyPluginEditPage() {
  // Capture the form instance via an onFormReady callback from your form component
  const formRef = useRef<UseFormReturn<any> | null>(null)
  const handleFormReady = useCallback((form: UseFormReturn<any>) => {
    formRef.current = form
  }, [])

  useRegisterPageAIContext({
    // Short identifier shown as a badge in the chat widget header
    routeName: "my-plugin-edit",

    // Injected into the AI system prompt (capped at 8,000 characters)
    pageDescription: "User is editing a My Plugin item. When asked to fill in the form, call the fillMyPluginForm tool.",

    // Quick-action chips shown in the chat empty state (merged with static suggestions)
    suggestions: ["Fill in the form for me", "Suggest a title"],

    // Handlers the AI can invoke — keyed by tool name
    clientTools: {
      fillMyPluginForm: async ({ title, description }) => {
        const form = formRef.current
        if (!form) return { success: false, message: "Form not ready" }
        if (title !== undefined) form.setValue("title", title, { shouldValidate: true })
        if (description !== undefined) form.setValue("description", description)
        return { success: true, message: "Form filled" }
      },
    },
  })

  return <MyPluginForm onFormReady={handleFormReady} />
}

Pass null to conditionally disable the context while data is loading:

useRegisterPageAIContext(item ? {
  routeName: "my-plugin-detail",
  pageDescription: `Viewing: "${item.title}"\n\n${item.content?.slice(0, 16000)}`,
  suggestions: ["Summarize this", "What are the key points?"],
} : null)

Step 2 — Register the tool schema server-side

Client-side tool handlers need a matching server-side schema so the LLM knows what parameters to send.

For first-party BTST plugins, add the schema to BUILT_IN_PAGE_TOOL_SCHEMAS in src/plugins/ai-chat/api/page-tools.ts:

// packages/stack/src/plugins/ai-chat/api/page-tools.ts
import { tool } from "ai"
import { z } from "zod"

export const BUILT_IN_PAGE_TOOL_SCHEMAS: Record<string, Tool> = {
  // ...existing built-in tools (fillBlogForm, updatePageLayers)

  fillMyPluginForm: tool({
    description: "Fill in the my-plugin form fields. Call this when the user asks to populate or draft the form.",
    inputSchema: z.object({
      title: z.string().optional().describe("The item title"),
      description: z.string().optional().describe("A short description"),
    }),
    // No execute — this is handled entirely client-side via onToolCall in ChatInterface
  }),
}

For consumer (third-party) plugins, instruct users to pass clientToolSchemas in aiChatBackendPlugin:

// Consumer's lib/stack.ts
aiChatBackendPlugin({
  model: openai("gpt-4o"),
  enablePageTools: true,
  clientToolSchemas: {
    fillMyPluginForm: tool({
      description: "Fill in the my-plugin form fields",
      parameters: z.object({ title: z.string().optional() }),
    }),
  },
})

Step 3 — Ensure PageAIContextProvider is in the root layout

The PageAIContextProvider must be present above all StackProvider instances in every example app's root layout. It is already wired up in the BTST example apps — you only need to ensure your plugin's pages call useRegisterPageAIContext correctly.

useRegisterPageAIContext silently no-ops when PageAIContextProvider is absent from the tree. If context doesn't appear in the chat widget, check that the provider wraps the root layout.

Read-only context (no tools)

If your page only displays content the AI should be able to read but not mutate, omit clientTools:

// Blog post detail page — AI can summarize but not write
useRegisterPageAIContext(post ? {
  routeName: "blog-post",
  pageDescription: `Blog post: "${post.title}"\n\n${post.content?.slice(0, 16000)}`,
  suggestions: ["Summarize this post", "What are the key takeaways?"],
} : null)

Reference implementations inside BTST

PluginFileTools exposed
Blog (new post)blog/client/components/pages/new-post-page.internal.tsxfillBlogForm
Blog (edit post)blog/client/components/pages/edit-post-page.internal.tsxfillBlogForm
Blog (post detail)blog/client/components/pages/post-page.internal.tsxnone (read-only)
UI Builderui-builder/client/components/pages/page-builder-page.internal.tsxupdatePageLayers

Reference Implementations

Simple Plugin: Todo

A basic CRUD plugin demonstrating core concepts:

Source Code: lib/plugins/todo/ in the generated Next.js project (run bash scripts/codegen/setup-nextjs.sh)

Features:

  • Basic CRUD operations
  • Database schema
  • API endpoints (list, create, update, delete)
  • Server-side getter functions (getters.ts)
  • stack().api.todos.* surface for direct server-side access
  • Client components and hooks

A production-ready plugin with advanced features:

Source Code: Blog Plugin

Features:

  • Multiple related models (posts, tags, postTags)
  • Complex queries with pagination, filtering, search
  • SSR data loading with React Query
  • SSG support via prefetchForRoute — seeds the query cache at build time without HTTP
  • api/query-key-defs.ts — shared key constants used by both query-keys.ts and prefetchForRoute
  • api/serializers.tsDate → ISO string conversion for consistent cache hydration
  • SEO meta generation
  • Sitemap generation
  • Authorization and lifecycle hooks
  • Optimistic updates
  • Rich text editing

Publishing Plugins

To create a standalone plugin package for npm, use the Plugin Starter repository:

🚀 Plugin Starter Repository

The starter provides:

  • Complete monorepo setup with build tooling
  • Example plugin you can modify
  • Next.js example app for testing
  • E2E testing with Playwright
  • GitHub Actions for automated publishing

Clone it, modify the plugin package, and publish to npm under your own account.