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 may expose either or both of these independent parts:

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

Do not invent a matching half solely for symmetry. OpenAPI is the reference backend-only plugin, Route Docs is client-only, and UI Builder is client-only over CMS. Programmatic IDs use camelCase even when package and URL slugs use kebab-case.

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/
│   ├── constants.ts    # Literal plugin ID shared by factory and components
│   ├── client.tsx      # Client plugin with routes
│   ├── hooks.tsx       # React Query hooks
│   ├── components.tsx  # Page components
│   └── overrides.ts    # Plugin-specific browser customization
├── 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
  defineRoute,          // Define a route declaratively
  createRoute,          // Low-level route with a handler closure
  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 schema-backed operations first, then adapts those same operations to HTTP routes, request-scoped calls, and trusted calls. A route-only business plugin is rejected when server authorization is configured because its access would be unclassified.

Schema-backed operations

For every user-facing read or write, define one operation and let each server transport adapt it. Start with a browser-safe permission catalog:

permissions.ts
import { definePermissions, permission } from "@btst/stack/authorization"
import { z } from "zod"

export const todoPermissions = definePermissions("todos", {
  todo: {
    delete: permission(z.object({
      id: z.string(),
      ownerId: z.string(),
    })),
  },
})

Then declare the operation next to the backend plugin. Its facts callback loads trusted server state; it must not accept ownership or tenant facts from the browser. The route factory receives transport-bound operations as its required third argument:

api/backend.ts
import {
  createEndpoint,
  defineBackendPlugin,
  defineOperation,
} from "@btst/stack/plugins/api"
import { z } from "zod"
import { todoPermissions } from "../permissions"

export const todosBackendPlugin = () => defineBackendPlugin({
  id: "todos",
  dbPlugin: todosSchema,

  operations: (adapter) => ({
    deleteTodo: defineOperation({
      input: z.object({ id: z.string() }),
      permission: todoPermissions.todo.delete,
      facts: async ({ input }) => {
        const todo = await adapter.findOne<Todo>({
          model: "todo",
          where: [{ field: "id", value: input.id }],
        })
        if (!todo) throw new Error("Todo not found")
        return { id: todo.id, ownerId: todo.ownerId }
      },
      before: ({ identity, input, facts, request }) => {
        // Domain lifecycle only; shared role/ownership policy belongs in
        // defineAuthorization(). This hook runs after authorization.
      },
      execute: async ({ input }) => {
        await adapter.delete({
          model: "todo",
          where: [{ field: "id", value: input.id }],
        })
        return { success: true } as const
      },
      after: ({ identity, input, facts, result, request }) => {
        // Audit or invalidate after a successful write.
      },
      onError: ({ identity, input, facts, error, request }) => {
        // Observes only failures after authorization succeeds.
      },
    }),
  }),

  routes: (_adapter, _context, operations) => ({
    deleteTodo: createEndpoint(
      "/todos/:id",
      { method: "DELETE", requireRequest: true },
      operations.deleteTodo.route((ctx) => ({ id: ctx.params.id })),
    ),
  }),
})

export type TodosApiRouter = ReturnType<
  ReturnType<typeof todosBackendPlugin>["routes"]
>

For compound behavior, additionalPermissions derives every secondary check from the already validated input and trusted primary facts:

additionalPermissions: async ({ input }) => {
  const related = await loadRelatedTodo(input.id)
  return related
    ? [todoPermissions.todo.delete({ id: related.id, ownerId: related.ownerId })]
    : []
},

Every returned schema-backed request is authorized before before or execute runs. For request execution, BTST authorizes the operation's primary permission before invoking this callback, so a primary denial cannot trigger secondary reads or be replaced by a callback error. The callback may load trusted server state to build those requests, but it must not mutate it. trusted execution still derives the requests and runs the lifecycle; it skips only user authorization.

Once a plugin declares operations, every composed HTTP route must have a same-key operation and use operations.operationKey.route(ctx => input) as its endpoint handler. The generated handler owns execution of that exact request-bound operation and carries a private transport identity; createBackendStack() validates both the inventory and exact binding during composition. It reports the plugin key, route key, method, and path for an undeclared or unbound route. This keeps a new route from silently bypassing the operation pipeline. Extra operations may remain request/trusted-only and are not exposed as HTTP merely because they exist in the operation registry.

The route mapper may parse or normalize already validated request data, but it must not read or mutate trusted application state. Trusted reads belong in the operation's facts stage, and all writes belong in execute, after the permission check succeeds.

Throw OperationHttpError for a domain failure that is safe to expose through HTTP. Authorization denials and validation failures are mapped automatically; ordinary identity, rule, fact, and execution errors remain internal failures.

Keep route and operation keys identical by default. If an existing public router key cannot be renamed, declare only that mismatch explicitly with operationRouteMap: { existingRouteKey: "operationKey" }. Stale route keys and unknown operation targets fail composition.

True infrastructure handlers should not be disguised as business operations. Declare the smallest possible route-key allowlist with a public access marker and a concrete rationale:

defineBackendPlugin({
  id: "reference",
  dbPlugin: referenceSchema,
  infrastructureRoutes: {
    schema: {
      access: "public",
      rationale: "Serves generated metadata; it does not execute business behavior.",
    },
  },
  routes: () => ({
    schema: createEndpoint("/schema", { method: "GET" }, generateSchema),
  }),
})

The allowlist is exact: a missing declaration, stale key after a rename, empty rationale, or route declared as both operation-backed and infrastructure fails composition. Public infrastructure still runs the handler's validation and security/domain checks; the declaration does not turn other routes public.

Register the same catalog in the application's server authorization. createBackendStack() rejects catalog mismatches at typecheck time. Call the composed operation—not the descriptor—from application code:

await myStack.forRequest(request).operations.todos.deleteTodo({ id: todoId })
await myStack.trusted.todos.deleteTodo({ id: todoId })

The request-scoped operations authorize. The explicit trusted namespace skips user authorization but still validates input, derives facts, and runs the lifecycle. Operation descriptors do not expose a forgeable trusted run option. Lifecycle input, facts, identity, and results use primitives, plain objects, and arrays; they are deeply readonly and frozen. Serialize mutable built-ins such as Date, Map, Set, and typed arrays before they cross this boundary, so lifecycle code cannot change the target or claims after the authorization decision.

Each Operation also publishes its access: OperationAccess and resultMode: OperationResultMode metadata. Access defaults to "authorized". Use access: "public" only for an intentional operation whose validation, trusted facts, hooks, and domain behavior still run without an identity rule. It never makes the rest of the plugin public and is not a replacement for an explicit allow rule.

Ordinary operations use resultMode: "immutable": their request and trusted results are recursively frozen and HTTP routes serialize them normally. A transport-native value such as a streaming Response must opt in with definePassthroughOperation:

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

const streamReport = definePassthroughOperation({
  input: z.object({ reportId: z.string() }),
  permission: reportPermissions.report.stream,
  access: "authorized", // or an intentional, operation-local "public"
  facts: async ({ input }) => loadTrustedReportFacts(input.reportId),
  execute: async () => new Response(reportStream),
  onError: ({ error }) => reportLifecycleFailure(error),
})

Passthrough preserves the exact result type and runtime identity across HTTP, forRequest().operations, and trusted; input validation, authorization, lifecycle ordering, and error hooks are unchanged. Keep this opt-in limited to values that cannot be represented as immutable operation data.

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

Operation-first authorization and lifecycle

Publish schema-backed permission descriptors, then define one maintained operation inventory. Each protected operation validates input, derives authoritative facts, evaluates its exact descriptor through createServerAuth, executes domain behavior, and invokes lifecycle hooks. Hooks are for domain invariants, side effects, audit, and error reporting—not routine authorization or input transformation.

const todoPermissions = definePermissions("todo", {
  item: {
    read: permission(z.object({ id: z.string(), ownerId: z.string().optional() })),
    create: permission(),
  },
})

const operations = (adapter: Adapter) => ({
  createTodo: defineOperation({
    input: CreateTodoSchema,
    permission: todoPermissions.item.create,
    facts: () => undefined,
    execute: async ({ input, identity }) => createTodo(adapter, {
      ...input,
      ownerId: identity?.id,
    }),
  }),
})

export const todosBackendPlugin = () => defineBackendPlugin({
  id: "todos",
  dbPlugin: dbSchema,
  operations,
  raw: (adapter) => ({ prefetchForRoute: createTodoPrefetchForRoute(adapter) }),
  routes: (_adapter, _context, operations) => ({
    createTodo: createEndpoint(
      "/todos",
      { method: "POST", body: CreateTodoSchema, requireRequest: true },
      operations.createTodo.route((ctx) => ctx.body),
    ),
  }),
})

Use app.forRequest(request).operations.todos.* for request-driven server work and app.trusted.todos.* for explicitly trusted jobs. Trusted calls skip user authorization but retain validation, fact derivation, domain behavior, and lifecycle hooks. First-party backend stack raw namespaces are narrow SSG prefetch surfaces only.

Pure getters and mutations may remain exported lower-level adapter primitives for plugin internals and migrations. They do not promise authorization, validation, or lifecycle composition and should not be duplicated onto the backend stack's raw surface.

Client Plugin

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

Basic Structure

Keep the literal plugin ID in a browser-safe constants module so the factory and plugin components use the same value:

client/constants.ts
export const TODOS_PLUGIN_ID = "todos" as const
import {
  createApiClient,
  defineClientPlugin,
  defineRoute,
  type ResolvedClientPluginRuntime,
} from "@btst/stack/plugins/client"
import type { QueryClient } from "@tanstack/react-query"
import type { TodosApiRouter } from "../api/backend"
import { lazy } from "react"
import { TODOS_PLUGIN_ID } from "./constants"

export interface TodosClientConfig {
  title?: string
}

interface ResolvedTodosClientConfig {
  title: string
  queryClient: QueryClient
  apiBaseURL: string
  apiBasePath: string
  siteBaseURL: string
  siteBasePath: string
  headers?: Headers
  credentials?: RequestCredentials
}

const TodosListPage = lazy(() =>
  import("./components").then((m) => ({ default: m.TodosListPage }))
)

function resolveTodosClientConfig(
  config: TodosClientConfig,
  runtime: ResolvedClientPluginRuntime<typeof TODOS_PLUGIN_ID>,
): ResolvedTodosClientConfig {
  return {
    title: config.title ?? "Todos",
    queryClient: runtime.queryClient,
    apiBaseURL: runtime.api.baseURL,
    apiBasePath: runtime.api.basePath,
    siteBaseURL: runtime.site.baseURL,
    siteBasePath: runtime.site.basePath,
    ...(runtime.api.headers ? { headers: runtime.api.headers } : {}),
    ...(runtime.api.credentials
      ? { credentials: runtime.api.credentials }
      : {}),
  }
}

function createResolvedTodosPlugin(config: ResolvedTodosClientConfig) {
  return {
    routes: () => ({
      todos: defineRoute("/todos", {
        page: TodosListPage,
        loader: todosLoader(config),
        meta: createTodosMeta(config, "/todos"),
      }),
    }),
    
    sitemap: async () => [
      { 
        url: `${config.siteBaseURL}${config.siteBasePath}/todos`, 
        lastModified: new Date(), 
        priority: 0.7 
      },
    ],
  }
}

export const todosClientPlugin = (config: TodosClientConfig = {}) =>
  defineClientPlugin()({
    id: TODOS_PLUGIN_ID,
    resolve: (runtime) =>
      createResolvedTodosPlugin(resolveTodosClientConfig(config, runtime)),
  })

TodosClientConfig contains only Todo-specific choices. The plugin receives API, site, QueryClient, headers, and credentials from the enclosing createClientStack() through resolve(runtime).

When browser components must observe a factory choice, expose only that small, browser-safe value through providerConfig:

export const todosClientPlugin = (config: TodosClientConfig = {}) =>
  defineClientPlugin()({
    id: TODOS_PLUGIN_ID,
    providerConfig: {
      title: config.title ?? "Todos",
    },
    resolve: (runtime) =>
      createResolvedTodosPlugin(resolveTodosClientConfig(config, runtime)),
  })

The resolved stack preserves the exact shape at useStack().plugins?.todos?.config. providerConfig is not a second general configuration bucket: never put request headers, secrets, server-only objects, or shared API/site/QueryClient runtime there. Keep the full factory config in the server-safe definition closure and project only the plain values that rendered components actually need.

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: ResolvedTodosClientConfig) {
  return async () => {
    // Only run on server
    if (typeof window === "undefined") {
      const { queryClient, apiBasePath, apiBaseURL, headers, credentials } = config
      
      try {
        await queryClient.prefetchQuery({
          queryKey: ["todos"],
          queryFn: async () => {
            const client = createApiClient<TodosApiRouter>({
              baseURL: apiBaseURL,
              basePath: apiBasePath,
              headers,
              credentials,
            })
            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.raw.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: ResolvedTodosClientConfig, path: string) {
  return () => {
    const { queryClient, siteBaseURL, siteBasePath } = config
    const todos = queryClient.getQueryData<Todo[]>(["todos"]) ?? []
    const fullUrl = `${siteBaseURL}${siteBasePath}${path}`
    
    const title = `${config.title} · ${todos.length}`

    return [
      { name: "title", content: title },
      { name: "description", content: `Track ${todos.length} todos.` },
      { property: "og:title", content: title },
      { 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 raw 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({
  id: "todos",
  dbPlugin: dbSchema,
  raw: (adapter) => ({
    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/(static)/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.raw.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.raw.todos.prefetchForRoute("list", queryClient)
  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <route.PageComponent />
    </HydrationBoundary>
  )
}

Keep request-aware routes under app/(request)/pages and SSG/ISR routes under app/(static)/pages; both groups still publish /pages/* URLs. Put the shared client StackProvider shell in app/pages/client-layout.tsx. The request group layout resolves trusted request origins, while the static group layout calls the header-free getServerClientOrigins() so reading headers cannot make SSG routes dynamic.

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: {
    onAfterCreateTodo: async (item) => {
      revalidatePath("/todos")
    },
    onAfterUpdateTodo: async (item) => {
      revalidatePath("/todos")
    },
    onAfterDeleteTodo: 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"] })
    }
  })
}

Plugin Site Navigation

Framework-wide routing is configured once on StackProvider. In plugin browser components, resolve links through usePluginSiteNavigation() with the plugin's literal ID. This honors both the top-level site location and a per-plugin endpoints.todos.site override:

"use client"

import { usePluginSiteNavigation } from "@btst/stack/context"
import { TODOS_PLUGIN_ID } from "./constants"

function TodosList() {
  const { Link, navigate, resolve } = usePluginSiteNavigation(TODOS_PLUGIN_ID)

  return (
    <>
      <Link href={resolve("todos", "add").href}>Add Todo</Link>
      <button type="button" onClick={() => void navigate("todos")}>
        View Todos
      </button>
    </>
  )
}

For same-origin destinations, the hook uses the framework router. Cross-origin plugin endpoints render an absolute link and use full-page browser navigation. It also falls back to full-page navigation when no router adapter is installed, and normalizes a root base path (/) without producing a protocol-relative URL.

Do not build plugin-owned browser links from useBasePath() alone: it exposes the top-level site base path and cannot see a plugin endpoint override. Keep joinBasePath(basePath, route) for non-hook or server-side path composition when the correct resolved base path is already available.

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

export const myStack = createBackendStack({
  basePath: "/api/data",
  plugins: { todos: todosBackendPlugin(), blog: blogBackendPlugin() },
  adapter: (db) => createMemoryAdapter(db)({}),
  auth: serverAuth,
})

export const { handler, dbSchema } = myStack

await myStack.forRequest(request).operations.todos.createTodo({ title: "Request" })
await myStack.trusted.todos.createTodo({ title: "Trusted job" })
await myStack.raw.todos.prefetchForRoute("list", queryClient)

Client Registration

Register client plugins with your stack client:

import { createClientStack } 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 createClientStack({
    api: { baseURL, basePath: "/api/data" },
    site: { baseURL, basePath: "/pages" },
    queryClient,
    plugins: {
      todos: todosClientPlugin(),
      blog: blogClientPlugin({
        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

Generated examples follow the same operation-first shape: descriptors live in permissions.ts, operations own validation/facts/lifecycle, routes bind the same-key operations, and api exposes only prefetchForRoute. Use the generated Todo plugin as a compact reference and the first-party Blog plugin for relations, lifecycle, SSR, and authorization examples.

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)
  • operation-first request and trusted server surfaces
  • 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.