BTST

Installation

Learn how to install and configure BTST in your project.

AI Agent Skills

If you're using an AI coding agent (Cursor, Claude Code, VS Code, OpenAI Codex etc.) you can install the BTST integration skill so your agent understands the plugin system, adapter setup, and wiring patterns out of the box:

npx skills@latest add better-stack-ai/better-stack/.agents/skills/btst-integration

Or manually copy the SKILL.md file into your project's agent skills directory.


Prerequisites

In order to use BTST, your application must meet the following requirements:

  • shadcn/ui installed with CSS variables enabled - Plugins use shadcn/ui components. To verify CSS variables are enabled, check that your components.json has "cssVariables": true or your Tailwind config uses CSS variables for colors.
  • Sonner <Toaster /> component configured for toast notifications
  • TailwindCSS v4 set up and configured correctly - Plugins use Tailwind classes and utilities
  • @tanstack/react-query installed - Required for server-side prefetching and client-side data fetching/state management

Install the Package

Let's start by adding BTST to your project:

npm install @btst/stack @tanstack/react-query
pnpm add @btst/stack @tanstack/react-query
yarn add @btst/stack @tanstack/react-query

BTST plugins require @tanstack/react-query for server-side prefetching and client-side data fetching and state management.

Install Database Adapter

BTST requires a database adapter to work with your database. Choose one based on your setup:

For Prisma ORM:

npm install @btst/adapter-prisma
pnpm add @btst/adapter-prisma
yarn add @btst/adapter-prisma

For Drizzle ORM:

npm install @btst/adapter-drizzle
pnpm add @btst/adapter-drizzle
yarn add @btst/adapter-drizzle

For Kysely query builder:

npm install @btst/adapter-kysely
pnpm add @btst/adapter-kysely
yarn add @btst/adapter-kysely

For MongoDB:

npm install @btst/adapter-mongodb
pnpm add @btst/adapter-mongodb
yarn add @btst/adapter-mongodb

For development and testing, use the in-memory adapter:

npm install @btst/adapter-memory
pnpm add @btst/adapter-memory
yarn add @btst/adapter-memory

Create Backend Instance

Create a file named stack.ts in your lib/ folder to configure the backend API:

lib/stack.ts
import { stack } from "@btst/stack"
import { createPrismaAdapter } from "@btst/adapter-prisma"
import { PrismaClient } from "@prisma/client"

const prisma = new PrismaClient()

const { handler, dbSchema } = stack({
  basePath: "/api/data",
  plugins: {
    // Add your backend plugins here
  },
  adapter: (db) => createPrismaAdapter(prisma, db, { 
    provider: "postgresql" // or "mysql", "sqlite", "cockroachdb", "mongodb"
  })
})

export { handler, dbSchema }
lib/stack.ts
import { stack } from "@btst/stack"
import { createDrizzleAdapter } from "@btst/adapter-drizzle"
import { drizzle } from "drizzle-orm/postgres-js" // or "drizzle-orm/mysql2", "drizzle-orm/better-sqlite3", etc.
import postgres from "postgres"

const client = postgres(process.env.DATABASE_URL!)
const drizzleDb = drizzle(client)

const { handler, dbSchema } = stack({
  basePath: "/api/data",
  plugins: {
    // Add your backend plugins here
  },
  adapter: (db) => createDrizzleAdapter(drizzleDb, db, {})
})

export { handler, dbSchema }
lib/stack.ts
import { stack } from "@btst/stack"
import { createKyselyAdapter } from "@btst/adapter-kysely"
import { Kysely, PostgresDialect } from "kysely"
import { Pool } from "pg"

const kyselyDb = new Kysely({
  dialect: new PostgresDialect({
    pool: new Pool({ connectionString: process.env.DATABASE_URL })
  })
})

const { handler, dbSchema } = stack({
  basePath: "/api/data",
  plugins: {
    // Add your backend plugins here
  },
  adapter: (db) => createKyselyAdapter(kyselyDb, db, {})
})

export { handler, dbSchema }
lib/stack.ts
import { stack } from "@btst/stack"
import { createMongodbAdapter } from "@btst/adapter-mongodb"
import { MongoClient } from "mongodb"

const client = new MongoClient(process.env.MONGODB_URI!)
const mongoDb = client.db()

const { handler, dbSchema } = stack({
  basePath: "/api/data",
  plugins: {
    // Add your backend plugins here
    // blog: blogBackendPlugin()
  },
  adapter: (db) => createMongodbAdapter(mongoDb, db, {})
})

export { handler, dbSchema }
lib/stack.ts
// IMPORTANT: Memory adapter is used for development and testing only
import { stack } from "@btst/stack"
import { createMemoryAdapter } from "@btst/adapter-memory"

const { handler, dbSchema } = stack({
  basePath: "/api/data",
  plugins: {
    // Add your backend plugins here
  },
  adapter: (db) => createMemoryAdapter(db)({})
})

export { handler, dbSchema }

What happens here:

  • stack() collects all plugin database schemas and merges them into a unified dbSchema
  • The basePath determines where your API is mounted (e.g., /api/data/*)
  • The adapter function receives this merged schema (db) and returns an adapter that translates BTST's database operations to your ORM
  • The handler is a request handler function (request: Request) => Promise<Response> that processes all API calls

Now you can generate database schema using the CLI (not needed for mongodb):

npx @btst/cli generate --config=lib/stack.ts --orm=prisma --output=schema.prisma
npx @btst/cli generate --config=lib/stack.ts --orm=drizzle --output=src/db/schema.ts

Kysely requires a database connection for introspection:

Using DATABASE_URL environment variable:

DATABASE_URL=sqlite:./dev.db npx @btst/cli generate --config=lib/stack.ts --orm=kysely --output=migrations/schema.sql

Or using --database-url flag:

npx @btst/cli generate --config=lib/stack.ts --orm=kysely --output=migrations/schema.sql --database-url=sqlite:./dev.db
npx @btst/cli generate --config=lib/stack.ts --orm=kysely --output=migrations/schema.sql --database-url=postgres://user:pass@localhost:5432/db

See the CLI documentation for both:

  • npx @btst/codegen init project scaffolding
  • @btst/cli schema generation and migration commands.

Create API Route

Create a catch-all API route to handle BTST requests. The toNextRouteHandlers / toReactRouterHandlers / toTanStackHandlers helpers from the framework entry points wire your stack handler to every HTTP method the route needs. The route will handle requests for the path /api/data/*. If you use a different path make sure to update the basePath in the stack config to match your chosen path.

app/api/data/[[...all]]/route.ts
import { toNextRouteHandlers } from "@btst/stack/next"
import { handler } from "@/lib/stack"

export const { GET, POST, PUT, PATCH, DELETE } = toNextRouteHandlers(handler)
app/routes/api/data/$.ts
import { toReactRouterHandlers } from "@btst/stack/react-router"
import { handler } from "~/lib/stack"

// React Router's build can't strip destructured exports from route
// modules, so assign loader/action individually.
const handlers = toReactRouterHandlers(handler)
export const loader = handlers.loader
export const action = handlers.action
src/routes/api/data/$.ts
import { createFileRoute } from "@tanstack/react-router"
import { toTanStackHandlers } from "@btst/stack/tanstack"
import { handler } from "@/lib/stack"

export const Route = createFileRoute("/api/data/$")({
  server: { handlers: toTanStackHandlers(handler) },
})

For standalone Node.js servers (Express, Fastify, etc.), use toNodeHandler to convert the Web API handler to a Node.js-compatible handler:

server.ts
import express from "express"
import { handler } from "./lib/stack"
import { toNodeHandler } from "@btst/stack/api"

const app = express()

// Convert Web API handler to Node.js handler
const nodeHandler = toNodeHandler(handler)

// Mount at your basePath
app.use("/api/data", nodeHandler)

app.listen(3000, () => {
  console.log("Server running on http://localhost:3000")
})

Alternative: Using with Express middleware

server.ts
import express from "express"
import { handler } from "./lib/stack"
import { toNodeHandler } from "@btst/stack/api"

const app = express()
app.use(express.json()) // Parse JSON bodies

// Convert and mount BTST handler
app.all("/api/data/*", toNodeHandler(handler))

app.listen(3000)

Import Plugin Styles

Plugins use TailwindCSS v4, so you should add the following @import to your global css file to ensure proper styling:

app/globals.css
@import "@btst/stack/plugins/blog/css";

Each plugin may require its own CSS import. The import path follows the pattern @btst/stack/plugins/{plugin-name}/css. Check the plugin documentation for specific requirements.

Create Client Instance

Create a client instance that routes requests to plugin pages, prefetches their data on the server, and renders them with instant hydration on the client:

lib/stack-client.tsx
import { createStackClient } from "@btst/stack/client"
import { QueryClient } from "@tanstack/react-query"

export const getStackClient = (queryClient: QueryClient) => {
  return createStackClient({
    plugins: {
      // Add your client plugins here
    }
  })
}

Why a function? getStackClient takes a QueryClient because different contexts use different instances:

  • Server (SSR): Each request gets its own QueryClient (or cached per-request)
  • Client: A singleton QueryClient is shared across navigations
  • Additional options: You can pass additional options to the createStackClient function, such as headers for SSR authentication if plugins expose lifecycle hooks.

This pattern allows you to pass the appropriate QueryClient and other options for each context.

Set Up Query Client Provider

If you don't already have a query client utility, create one to ensure proper SSR hydration:

lib/query-client.ts
import { QueryClient, isServer } from "@tanstack/react-query"
import { cache } from "react"

function makeQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: {
        staleTime: isServer ? 60 * 1000 : 0,
        refetchOnMount: false,
        refetchOnWindowFocus: false,
        retry: false
      },
      dehydrate: {
        // Include both successful and error states to avoid refetching on the client
        // This prevents loading states when there's an error in prefetched data
        shouldDehydrateQuery: (query) => {
            return true
        }
      }
    }
  })
}

let browserQueryClient: QueryClient | undefined = undefined

export function getOrCreateQueryClient() {
    if (isServer) {
        // Server: always make a new query client
        return makeQueryClient();
    } else {
        // Browser: make a new query client if we don't already have one
        // This is very important, so we don't re-make a new client if React
        // suspends during the initial render. This may not be needed if we
        // have a suspense boundary BELOW the creation of the query client
        if (!browserQueryClient) browserQueryClient = makeQueryClient();
        return browserQueryClient;
    }
}

Then configure QueryClientProvider in your your app:

app/layout.tsx
import { QueryClientProvider } from "@tanstack/react-query"
import { getOrCreateQueryClient } from "@/lib/query-client"

export default function RootLayout({ children }) {
  const queryClient = getOrCreateQueryClient()
  
  return (
    <html>
      <body>
        <QueryClientProvider client={queryClient}>
          {children}
        </QueryClientProvider>
      </body>
    </html>
  )
}
app/root.tsx
import { QueryClientProvider } from "@tanstack/react-query"
import { getOrCreateQueryClient } from "~/lib/query-client"
import { Outlet } from "react-router"

export default function App() {
  const queryClient = getOrCreateQueryClient()
  
  return (
    <QueryClientProvider client={queryClient}>
      <Outlet />
    </QueryClientProvider>
  )
}
src/router.tsx
import { createRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
import { QueryClient } from '@tanstack/react-query'
import { setupRouterSsrQueryIntegration } from '@tanstack/react-router-ssr-query'
import { getOrCreateQueryClient } from '@/lib/query-client'

export interface MyRouterContext {
  queryClient: QueryClient
}

export function getRouter() {
  const queryClient = getOrCreateQueryClient()
  
  const router = createRouter({
    routeTree,
    scrollRestoration: true,
    defaultPreload: false,
    context: {
      queryClient,
    },
    notFoundMode: "root",
  })

  setupRouterSsrQueryIntegration({
    router,
    queryClient,
  })

  return router
}

declare module '@tanstack/react-router' {
  interface Register {
    router: ReturnType<typeof getRouter>
  }
}

The getOrCreateQueryClient() utility ensures:

  • Server: Each request gets its own QueryClient
  • Client: A singleton QueryClient prevents recreation during React Suspense
  • Hydration: Server-prefetched data seamlessly transfers to the client

Note: QueryClient might have to be configured differently in your framework of choice. See Example Projects or TanStack Query docs for more details.

Set Up Layout Provider

Wrap your BTST pages with the StackProvider. The framework router preset (router prop) wires Link, Image, navigate, and refresh for every plugin at once, and the api prop sets apiBaseURL/apiBasePath for all plugins. The overrides prop is then only needed for genuinely plugin-specific values:

app/pages/[[...all]]/layout.tsx
"use client"
import { StackProvider } from "@btst/stack/context"
import { nextRouter } from "@btst/stack/next"
import type { ExamplePluginOverrides } from "@btst/stack/plugins/example/client"

// Define the shape of all plugin overrides for type safety
type PluginOverrides = {
  example: ExamplePluginOverrides
  // Add other plugins here
}

const getBaseURL = () =>
  typeof window !== "undefined"
    ? window.location.origin
    : process.env.BASE_URL || "http://localhost:3000"

export default function Layout({ children }) {
  return (
    <StackProvider<PluginOverrides>
      basePath="/pages"
      router={nextRouter()}
      api={{ baseURL: getBaseURL(), basePath: "/api/data" }}
      overrides={{
        example: {
          // Only plugin-specific overrides needed here
        }
        // Add other plugins here
      }}
    >
      {children}
    </StackProvider>
  )
}
app/routes/pages/_layout.tsx
import { Outlet } from "react-router"
import { StackProvider } from "@btst/stack/context"
import { reactRouter } from "@btst/stack/react-router"
import type { ExamplePluginOverrides } from "@btst/stack/plugins/example/client"

// Define the shape of all plugin overrides
type PluginOverrides = {
  example: ExamplePluginOverrides
  // Add other plugins here
}

const getBaseURL = () =>
  typeof window !== "undefined"
    ? window.location.origin
    : process.env.BASE_URL || "http://localhost:3000"

export default function Layout() {
  return (
    <StackProvider<PluginOverrides>
      basePath="/pages"
      router={reactRouter()}
      api={{ baseURL: getBaseURL(), basePath: "/api/data" }}
      overrides={{
        example: {
          // Only plugin-specific overrides needed here
        }
        // Add other plugins here
      }}
    >
      <Outlet />
    </StackProvider>
  )
}
src/routes/pages/route.tsx
import { StackProvider } from "@btst/stack/context"
import { tanstackRouter } from "@btst/stack/tanstack"
import { QueryClientProvider } from "@tanstack/react-query"
import type { ExamplePluginOverrides } from "@btst/stack/plugins/example/client"
import { Outlet, createFileRoute } from "@tanstack/react-router"

// Define the shape of all plugin overrides
type PluginOverrides = {
  example: ExamplePluginOverrides
  // Add other plugins here
}

const getBaseURL = () =>
  typeof window !== "undefined"
    ? window.location.origin
    : process.env.BASE_URL || "http://localhost:3000"

export const Route = createFileRoute('/pages')({
  component: Layout
})

function Layout() {
  const context = Route.useRouteContext()

  return (
    <QueryClientProvider client={context.queryClient}>
      <StackProvider<PluginOverrides>
        basePath="/pages"
        router={tanstackRouter()}
        api={{ baseURL: getBaseURL(), basePath: "/api/data" }}
        overrides={{
          example: {
            // Only plugin-specific overrides needed here
          }
          // Add other plugins here
        }}
      >
        <Outlet />
      </StackProvider>
    </QueryClientProvider>
  )
}

Understanding Overrides:

  • Purpose: Injects framework-specific components via React Context. Plugin components access these overrides through usePluginOverrides() hook, allowing them to use your framework's Link, Image, and navigation without tight coupling and to avoid breaking the client/server boundary in frameworks like Next.js.
  • Resolution order: per-plugin overrides → top-level router/api → plugin defaults. Per-plugin Link/navigate/Image/apiBaseURL values always take precedence over the router preset, so you can still override framework wiring for a single plugin as an escape hatch.
  • Type Safety: Each plugin exports its override type (e.g., ExamplePluginOverrides)

Set Up Page Handler

Create a catch-all route to handle BTST pages defined in your plugins. The page factories from the framework entry points own the invariant plumbing once: server-side prefetching via route.loader(), React Query dehydration (including failed queries, so the client doesn't refetch on errors), loader-before-meta ordering for SEO metadata, and 404 handling via your framework's mechanism.

app/pages/[[...all]]/page.tsx
import { createNextPage } from "@btst/stack/next"
import { getOrCreateQueryClient } from "@/lib/query-client"
import { getStackClient } from "@/lib/stack-client"

export const dynamic = "force-dynamic"

const page = createNextPage({ getStackClient, getQueryClient: getOrCreateQueryClient })
export default page.Page
export const generateMetadata = page.generateMetadata
app/routes/pages/$.tsx
import { createReactRouterPage } from "@btst/stack/react-router"
import { getOrCreateQueryClient } from "~/lib/query-client"
import { getStackClient } from "~/lib/stack-client"

const page = createReactRouterPage({ getStackClient, getQueryClient: getOrCreateQueryClient })
export const loader = page.loader
export const meta = page.meta
export const ErrorBoundary = page.ErrorBoundary
export default page.Component
src/routes/pages/$.tsx
import { createFileRoute } from "@tanstack/react-router"
import { createTanStackPageOptions } from "@btst/stack/tanstack"
import { getStackClient } from "@/lib/stack-client"

export const Route = createFileRoute("/pages/$")(
  createTanStackPageOptions({ getStackClient }),
)

How it works:

The factory matches the URL to a plugin route via stackClient.router.getRoute(path), prefetches data server-side with route.loader(), renders the route's PageComponent with instant hydration on the client, and generates SEO metadata from route.meta() (running the loader first, so meta can read prefetched data).

Escape hatches:

  • createNextPage accepts notFound (replaces notFound() from next/navigation), wrapPage, and dehydrateOptions
  • createReactRouterPage accepts NotFound (component rendered when no route matches), ErrorBoundary (production-safe error UI), wrapPage, and dehydrateOptions
  • createTanStackPageOptions accepts getQueryClient (defaults to the router context's queryClient); spread extra route options like notFoundComponent alongside it
  • Hand-writing the route file remains fully supported — see below

Set Up Sitemap Generation (Optional)

Create a sitemap route to enable automatic sitemap generation for SEO. The library automatically collects URLs from all registered plugins.

How it works: Each plugin can export a sitemap() function that returns URLs with metadata (lastModified, changeFrequency, priority). The generateSitemap() method aggregates and deduplicates entries from all plugins.

app/sitemap.ts
import type { MetadataRoute } from "next"
import { QueryClient } from "@tanstack/react-query"
import { getStackClient } from "@/lib/stack-client"

export const dynamic = "force-dynamic"

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const queryClient = new QueryClient()
  const stackClient = getStackClient(queryClient)
  return stackClient.generateSitemap()
}
app/routes/sitemap.xml.ts
import type { Route } from "./+types/sitemap.xml"
import { QueryClient } from "@tanstack/react-query"
import { getStackClient } from "~/lib/stack-client"
import { sitemapEntryToXmlString } from "@btst/stack/client"

export async function loader({}: Route.LoaderArgs) {
  const queryClient = new QueryClient()
  const stackClient = getStackClient(queryClient)
  const entries = await stackClient.generateSitemap()
  const xml = sitemapEntryToXmlString(entries)

  return new Response(xml, {
    headers: {
      "Content-Type": "application/xml; charset=utf-8",
      "Cache-Control": "public, max-age=0, s-maxage=3600, stale-while-revalidate=86400",
    },
  })
}
src/routes/sitemap[.]xml.ts
// Note: [.] syntax in TanStack Router creates a route for "sitemap.xml"
import { createFileRoute } from "@tanstack/react-router"
import { QueryClient } from "@tanstack/react-query"
import { getStackClient } from "@/lib/stack-client"
import { sitemapEntryToXmlString } from "@btst/stack/client"

export const Route = createFileRoute("/sitemap.xml")({
  server: {
    handlers: {
      GET: async () => {
        const queryClient = new QueryClient()
        const stackClient = getStackClient(queryClient)
        const entries = await stackClient.generateSitemap()
        const xml = sitemapEntryToXmlString(entries)

        return new Response(xml, {
          headers: {
            "Content-Type": "application/xml; charset=utf-8",
            "Cache-Control": "public, max-age=0, s-maxage=3600, stale-while-revalidate=86400",
          },
        })
      },
    },
  },
})

The generateSitemap() method automatically collects URLs from all registered plugins. Each plugin can contribute its own routes to the sitemap with appropriate metadata like priority and change frequency. This step is optional but recommended for SEO.

🎉 That's it!

Your setup is complete! Here's what you've configured:

  • ✅ Backend API handler that processes all plugin requests
  • ✅ Database adapter that connects plugins to your database
  • ✅ Client-side router with SSR support
  • ✅ React Query integration for data fetching
  • ✅ Framework-specific overrides

Next steps:

  1. Add plugins to both backend and client configurations:

    • Backend: plugins: { blog: blogBackendPlugin() }
    • Client: plugins: { blog: blogClientPlugin() }
  2. Visit your pages at /pages/* to see plugin routes in action

Available plugins:

  • @btst/stack/plugins/blog - Full-featured blog with markdown editor, SEO, and RSS. Learn more about the blog plugin here.
  • More plugins coming soon!

Each plugin provides everything you need: routes, API endpoints, database schemas, React components, and hooks - all working together seamlessly.

Example Projects

See complete working examples for each framework:

Each example includes complete configuration, plugin setup, and demonstrates framework-specific patterns.