BTST

Installation

Learn how to install and configure BTST in your project.

Use the generated path for the shortest supported integration into an existing application. Use the manual reference when you need to control every route, adapter, provider, and deployment seam.

Want to evaluate the result without changing an app? Open the live Blog. The live demo uses /p/blog; the generated application below uses its own /pages/blog route.

Quickstart: add Blog to Next.js

This path adds the released Blog feature to an existing Next.js App Router application. It uses the memory adapter for a local evaluation, so it does not require a database migration and does not represent a production persistence choice.

Quickstart prerequisites

  • Node.js 22 and an existing Next.js App Router application
  • shadcn/ui initialized with CSS variables and Tailwind configured
  • a clean commit or working tree you can review after the generator patches it
  • the shadcn Button, dropdown menu, and Sonner components, with <Toaster /> rendered in the root layout

Install the UI prerequisites first:

npx shadcn@latest add button dropdown-menu sonner

Render the generated Sonner component once in your root layout. Keep your existing providers and layout markup in place:

app/layout.tsx
import { Toaster } from "@/components/ui/sonner"

// Inside the existing <body>:
{children}
<Toaster />

The generated runtime uses React Query. Codegen installs @tanstack/react-query, @btst/stack, and the selected adapter; the manual path below lists them explicitly.

1. Initialize Blog

Run this command from the existing application root:

npx @btst/codegen@0.2.0 init \
  --framework nextjs \
  --adapter memory \
  --plugins blog

Review conflict prompts before allowing generated files to replace application files. The command registers the Blog backend and client plugin, mounts the API and page catch-all routes, adds the required CSS, and wires the shared provider.

2. Configure the local origin

Add the trusted local origins to .env.local:

BTST_SITE_URL=http://localhost:3000
BTST_API_URL=http://localhost:3000

Use the deployed application origin for both values in a same-origin production deployment. The generated server integration fails closed during a production build when it cannot resolve a trusted site or API origin.

3. Start the app and verify the feature

npm run dev

Open http://localhost:3000/pages/blog. The Blog page should render successfully; it can be empty until you create content. That visible route—not the presence of generated files—is the quickstart success condition.

The generated Blog override leaves image upload as an explicit application TODO. The listing route works without it, but editor image uploads do not. The memory adapter also resets when the process restarts.

Before deploying, replace the memory adapter with the appropriate persistent adapter, complete plugin-specific TODOs, and verify a production build:

BTST_SITE_URL=http://localhost:3000 \
BTST_API_URL=http://localhost:3000 \
npm run build

Generated setup or manual setup?

Use @btst/codegen init when the application follows a supported framework shape and you want BTST to write the standard integration. Use the manual installation when the application has custom routing, provider composition, deployment boundaries, or file-layout constraints. The detailed manual material remains the source of truth for those seams.

Compatibility and prerequisites

Maintained framework paths

The released v3 integration and codegen paths are maintained and tested for:

FrameworkIntegration
Next.js 15+ App RouterRoute handlers, request-aware pages, static pages, metadata, and sitemap factories
React Router v7Framework routes, SSR loaders, navigation, metadata, and sitemap response helpers
TanStack StartFile routes, SSR loaders, navigation, metadata, and sitemap response helpers

Other React frameworks may be possible through custom adapters, but they are not part of the maintained/tested matrix above. Remix is not a separate v3 support claim.

Adapter choices

AdapterIntended use and important limits
Prisma, Drizzle, KyselyVersioned persistent adapter choices. Enable native isolated transactions for AI Chat, Form Builder, Kanban, or Media.
MongoDBVersioned adapter with plugin-specific limits; generated Form Builder and Media configurations reject it.
MemoryLocal, single-process evaluation and tests only; not a production persistence or isolation substitute.

See Database Adapters for provider configuration, transactions, generation, and migrations.

Shared UI and runtime requirements

  • shadcn/ui with CSS variables, plus the UI components used by the selected plugin
  • Tailwind CSS and the selected plugin CSS imports
  • Sonner with <Toaster /> rendered in the application layout
  • @tanstack/react-query and one QueryClientProvider

Plugin-specific exceptions

CapabilityRequirement
BlogImage uploads remain an application-provided override.
AI ChatRequires an AI SDK model provider and its credentials; persistent writes require an isolating adapter.
UI BuilderRequires CMS; codegen adds CMS when UI Builder is selected.
Form BuilderRequires an isolating Prisma, Drizzle, or Kysely configuration for persistent use.
MediaRequires a storage adapter; persistent writes require isolation, and MongoDB is not accepted by the generated configuration.
Better Auth UIRequires an existing Better Auth server endpoint and its aligned dependency cohort; it does not generate an auth backend.

Read the selected plugin page before installation for its exact package, service, storage, auth, and override requirements.

AI coding agent assistance (optional)

After you understand the product and normal installation path, you can install the BTST integration skill so a coding agent understands the plugin system, adapter setup, and wiring patterns:

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.


Manual installation

The steps below preserve the complete framework and adapter integration reference. Use them when generated setup is not appropriate or when you need to audit each layer explicitly.

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 { createBackendStack } from "@btst/stack/api"
import { createPrismaAdapter } from "@btst/adapter-prisma"
import { PrismaClient } from "@prisma/client"

const prisma = new PrismaClient()

const { handler, dbSchema } = createBackendStack({
  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 { createBackendStack } from "@btst/stack/api"
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 } = createBackendStack({
  basePath: "/api/data",
  plugins: {
    // Add your backend plugins here
  },
  adapter: (db) => createDrizzleAdapter(drizzleDb, db, {})
})

export { handler, dbSchema }
lib/stack.ts
import { createBackendStack } from "@btst/stack/api"
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 } = createBackendStack({
  basePath: "/api/data",
  plugins: {
    // Add your backend plugins here
  },
  adapter: (db) => createKyselyAdapter(kyselyDb, db, {})
})

export { handler, dbSchema }
lib/stack.ts
import { createBackendStack } from "@btst/stack/api"
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 } = createBackendStack({
  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 { createBackendStack } from "@btst/stack/api"
import { createMemoryAdapter } from "@btst/adapter-memory"

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

export { handler, dbSchema }

What happens here:

  • createBackendStack() 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)

Keep the API path in this route, createBackendStack({ basePath }), and createClientStack({ api: { basePath } }) identical. StackProvider receives that browser-safe projection through its stack prop.

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 {
  createClientStack,
  type ClientPluginEndpointOverride,
} from "@btst/stack/client"
import { blogClientPlugin } from "@btst/stack/plugins/blog/client"
import type { QueryClient } from "@tanstack/react-query"

interface StackClientOptions {
  apiOrigin?: string
  siteOrigin?: string
}

export const getStackClient = (
  queryClient: QueryClient,
  options?: StackClientOptions,
) => {
  const siteOrigin =
    options?.siteOrigin ||
    process.env.NEXT_PUBLIC_SITE_URL ||
    process.env.NEXT_PUBLIC_BASE_URL ||
    (typeof window === "undefined"
      ? process.env.BASE_URL || "http://localhost:3000"
      : window.location.origin)
  const apiOrigin =
    options?.apiOrigin ||
    process.env.NEXT_PUBLIC_API_URL ||
    process.env.NEXT_PUBLIC_BASE_URL ||
    siteOrigin
  const crossOriginApiEndpoint =
    apiOrigin === siteOrigin
      ? undefined
      : ({
          api: {
            baseURL: apiOrigin,
            basePath: "/api/data",
            credentials: "include",
          },
        } satisfies ClientPluginEndpointOverride)

  return createClientStack({
    api: { baseURL: apiOrigin, basePath: "/api/data" },
    site: { baseURL: siteOrigin, basePath: "/pages" },
    queryClient,
    plugins: {
      blog: blogClientPlugin(),
    },
    ...(crossOriginApiEndpoint
      ? { endpoints: { blog: crossOriginApiEndpoint } }
      : {}),
  })
}

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 API, site, QueryClient, and request-header configuration belongs on the client stack so SSR loaders, metadata, and browser hooks resolve the same runtime. Do not copy those services into plugin configuration or provider overrides.

Credentialed SSR stacks must resolve their API destination from deployment configuration, never from Host, forwarding headers, or request.url. Set BTST_API_URL for a managed/custom API and BTST_SITE_URL for the public site. For a same-origin deployment, BASE_URL can provide both. Generated stack-client.server.ts uses resolveTrustedClientOrigins from @btst/stack/client/server, fails closed when production configuration is missing, and removes routing and hop-by-hop headers before forwarding the remaining request credentials. The browser stack above separately opts each API-owning plugin into credentials: "include" only when the trusted API and site origins differ. A managed backend that uses cookies must allow the public site origin and credentialed requests in its CORS policy.

Existing Next.js scaffolds may keep NEXT_PUBLIC_BASE_URL as a narrow same-origin migration fallback when rerunning btst init. Prefer NEXT_PUBLIC_SITE_URL plus NEXT_PUBLIC_API_URL (or server-only BTST_SITE_URL/BTST_API_URL) for new deployment configuration.

Create the Query Client

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;
    }
}

The framework layouts in the next step install QueryClientProvider alongside StackProvider, so BTST pages have one provider boundary.

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 the Provider Layout

Put React Query and BTST's framework services around the /pages/* subtree. The resolved stack projects API, site, and QueryClient services; the provider adds the framework router and optional auth. The overrides object contains only plugin-specific UI or behavior such as Blog's upload function.

app/pages/client-layout.tsx
"use client"

import { useMemo, useState } from "react"
import { QueryClientProvider } from "@tanstack/react-query"
import { StackProvider } from "@btst/stack/context"
import { nextRouter } from "@btst/stack/next"
import { getOrCreateQueryClient } from "@/lib/query-client"
import { getStackClient, type StackClientOptions } from "@/lib/stack-client"
import { uploadImage } from "@/lib/uploads"

export function PagesClientLayout({ children, clientOrigins }: {
  children: React.ReactNode
  clientOrigins: StackClientOptions
}) {
  const [queryClient] = useState(() => getOrCreateQueryClient())
  const clientStack = useMemo(
    () => getStackClient(queryClient, clientOrigins),
    [clientOrigins.apiOrigin, clientOrigins.siteOrigin, queryClient],
  )

  return (
    <QueryClientProvider client={queryClient}>
      <StackProvider
        stack={clientStack}
        router={nextRouter()}
        overrides={{ blog: { uploadImage } }}
      >
        {children}
      </StackProvider>
    </QueryClientProvider>
  )
}
app/(request)/pages/layout.tsx
import { headers } from "next/headers"
import { PagesClientLayout } from "../../pages/client-layout"
import { getServerClientOriginsFromHeaders } from "@/lib/stack-client.server"

export default async function PagesLayout({ children }) {
  const clientOrigins = getServerClientOriginsFromHeaders(await headers())
  return (
    <PagesClientLayout clientOrigins={clientOrigins}>
      {children}
    </PagesClientLayout>
  )
}

Put SSG/ISR routes under app/(static)/pages and give that group a header-free layout using getServerClientOrigins(). Both route groups keep the /pages/* URL; the split prevents request headers from making static routes dynamic.

app/routes/pages/_layout.tsx
import { useMemo, useState } from "react"
import { Outlet, useLoaderData, type LoaderFunctionArgs } from "react-router"
import { QueryClientProvider } from "@tanstack/react-query"
import { StackProvider } from "@btst/stack/context"
import { reactRouter } from "@btst/stack/react-router"
import { getOrCreateQueryClient } from "~/lib/query-client"
import { getStackClient } from "~/lib/stack-client"
import { getServerClientOrigins } from "~/lib/stack-client.server"
import { uploadImage } from "~/lib/uploads"

export function loader({ request }: LoaderFunctionArgs) {
  return getServerClientOrigins(new URL(request.url).origin)
}

export default function PagesLayout() {
  const [queryClient] = useState(() => getOrCreateQueryClient())
  const { apiOrigin, siteOrigin } = useLoaderData<typeof loader>()
  const clientStack = useMemo(
    () => getStackClient(queryClient, { apiOrigin, siteOrigin }),
    [apiOrigin, queryClient, siteOrigin],
  )

  return (
    <QueryClientProvider client={queryClient}>
      <StackProvider
        stack={clientStack}
        router={reactRouter()}
        overrides={{ blog: { uploadImage } }}
      >
        <Outlet />
      </StackProvider>
    </QueryClientProvider>
  )
}
src/routes/pages/route.tsx
import { createFileRoute, Outlet } from "@tanstack/react-router"
import { QueryClientProvider } from "@tanstack/react-query"
import { StackProvider } from "@btst/stack/context"
import { tanstackRouter } from "@btst/stack/tanstack"
import { useMemo } from "react"
import { getStackClient } from "@/lib/stack-client"
import { getTrustedClientOrigins } from "@/lib/stack-client.origins"
import { uploadImage } from "@/lib/uploads"

export const Route = createFileRoute("/pages")({
  loader: async () => getTrustedClientOrigins(),
  component: PagesLayout,
})

function PagesLayout() {
  const { queryClient } = Route.useRouteContext()
  const { apiOrigin, siteOrigin } = Route.useLoaderData()
  const clientStack = useMemo(
    () => getStackClient(queryClient, { apiOrigin, siteOrigin }),
    [apiOrigin, queryClient, siteOrigin],
  )

  return (
    <QueryClientProvider client={queryClient}>
      <StackProvider
        stack={clientStack}
        router={tanstackRouter()}
        overrides={{ blog: { uploadImage } }}
      >
        <Outlet />
      </StackProvider>
    </QueryClientProvider>
  )
}

Add auth, notify, or i18n beside stack and router when your application needs them. See the auth provider guide. Never copy framework routing, API paths, or identity props into a plugin override. On Vite SSR, serialize only the trusted API/site origins into the parent loader so the server provider and hydrated browser stack resolve the same endpoints. TanStack client-navigation loaders should call the same generated getTrustedClientOrigins() server function. Keep request headers and server stack instances out of loader data.

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/(request)/pages/[[...all]]/page.tsx
import { createNextPage } from "@btst/stack/next"
import { headers } from "next/headers"
import { getOrCreateQueryClient } from "@/lib/query-client"
import { getStackClientForRequest } from "@/lib/stack-client.server"

export const dynamic = "force-dynamic"

const page = createNextPage({
  getStackClient: async (queryClient) =>
    getStackClientForRequest(queryClient, {
      headers: new Headers(await headers()),
    }),
  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).

Factory options:

  • createNextPage accepts an async getStackClient, plus notFound, wrapPage, and dehydrateOptions.
  • createReactRouterPage exposes createLoader() for async request-aware stack clients and accepts NotFound, ErrorBoundary, wrapPage, and dehydrateOptions.
  • createTanStackPageOptions accepts getLoaderStackClient for async context-aware loaders and getQueryClient when the QueryClient is not available from router context.

Use these options to customize the entry factory without reimplementing route matching, loader ordering, hydration, metadata, or 404 handling.

Request-aware stack clients

Keep session and authorization policy in your application. The entry factories pass each framework's native lifecycle context to an async resolver while preserving a synchronous client for browser rendering.

Next.js page and metadata functions run on the server, so the main resolver can await request headers and session state directly:

import { headers } from "next/headers"
import { createNextPage } from "@btst/stack/next"
import { getOrCreateQueryClient } from "@/lib/query-client"
import { getStackClientForRequest } from "@/lib/stack-client.server"

const page = createNextPage({
  getQueryClient: getOrCreateQueryClient,
  getStackClient: async (queryClient, pageProps) => {
    const requestHeaders = await headers()
    return getStackClientForRequest(queryClient, {
      headers: new Headers(requestHeaders),
      pageProps,
    })
  },
})

export default page.Page
export const generateMetadata = page.generateMetadata

React Router framework loaders are server-only, but the route component also renders in the browser. Keep the normal client synchronous and create a request-aware loader separately:

import { createReactRouterPage } from "@btst/stack/react-router"
import { getOrCreateQueryClient } from "~/lib/query-client"
import { getStackClient } from "~/lib/stack-client"
import { getStackClientForRequest } from "~/lib/stack-client.server"

const page = createReactRouterPage({
  getStackClient,
  getQueryClient: getOrCreateQueryClient,
})

export const loader = page.createLoader(
  async (queryClient, { request, context, params }) =>
    getStackClientForRequest(queryClient, {
      headers: request.headers,
      requestOrigin: new URL(request.url).origin,
      context,
      params,
    }),
)
export const meta = page.meta
export default page.Component

TanStack loaders run during SSR and browser navigation. Supply an isomorphic resolver and put request-derived session data in router context before the loader runs:

import type { QueryClient } from "@tanstack/react-query"
import { createFileRoute } from "@tanstack/react-router"
import { createTanStackPageOptions } from "@btst/stack/tanstack"
import type { AppSession } from "@/lib/auth"
import { getStackClient, getStackClientForLoad } from "@/lib/stack-client"

type AppRouterContext = {
  queryClient: QueryClient
  session: AppSession | null
}

export const Route = createFileRoute("/pages/$")(
  createTanStackPageOptions<AppRouterContext>({
    getStackClient,
    getLoaderStackClient: (queryClient, { context, params }) =>
      getStackClientForLoad(queryClient, {
        session: context.session,
        params,
      }),
  }),
)

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.

Verify the integration

The shared runtime is ready when the application has:

  • a mounted backend API handler and selected database adapter;
  • a resolved client stack, QueryClient, framework router, and provider;
  • the selected plugin registered on every side it actually provides;
  • plugin CSS and required UI components; and
  • trusted site and API origins for production rendering.

Run the application build, then open a route registered by the selected plugin. For Blog, /pages/blog is the visible success condition.

Continue with the released plugin catalog, or compare complete generated setups in the playground: