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 sonnerRender the generated Sonner component once in your root layout. Keep your existing providers and layout markup in place:
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 blogReview 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:3000Use 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 devOpen 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 buildGenerated 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:
| Framework | Integration |
|---|---|
| Next.js 15+ App Router | Route handlers, request-aware pages, static pages, metadata, and sitemap factories |
| React Router v7 | Framework routes, SSR loaders, navigation, metadata, and sitemap response helpers |
| TanStack Start | File 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
| Adapter | Intended use and important limits |
|---|---|
| Prisma, Drizzle, Kysely | Versioned persistent adapter choices. Enable native isolated transactions for AI Chat, Form Builder, Kanban, or Media. |
| MongoDB | Versioned adapter with plugin-specific limits; generated Form Builder and Media configurations reject it. |
| Memory | Local, 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-queryand oneQueryClientProvider
Plugin-specific exceptions
| Capability | Requirement |
|---|---|
| Blog | Image uploads remain an application-provided override. |
| AI Chat | Requires an AI SDK model provider and its credentials; persistent writes require an isolating adapter. |
| UI Builder | Requires CMS; codegen adds CMS when UI Builder is selected. |
| Form Builder | Requires an isolating Prisma, Drizzle, or Kysely configuration for persistent use. |
| Media | Requires a storage adapter; persistent writes require isolation, and MongoDB is not accepted by the generated configuration. |
| Better Auth UI | Requires 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-integrationOr 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.jsonhas"cssVariables": trueor 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-querypnpm add @btst/stack @tanstack/react-queryyarn add @btst/stack @tanstack/react-queryBTST 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-prismapnpm add @btst/adapter-prismayarn add @btst/adapter-prismaFor Drizzle ORM:
npm install @btst/adapter-drizzlepnpm add @btst/adapter-drizzleyarn add @btst/adapter-drizzleFor Kysely query builder:
npm install @btst/adapter-kyselypnpm add @btst/adapter-kyselyyarn add @btst/adapter-kyselyFor MongoDB:
npm install @btst/adapter-mongodbpnpm add @btst/adapter-mongodbyarn add @btst/adapter-mongodbFor development and testing, use the in-memory adapter:
npm install @btst/adapter-memorypnpm add @btst/adapter-memoryyarn add @btst/adapter-memoryCreate Backend Instance
Create a file named stack.ts in your lib/ folder to configure the backend API:
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 }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 }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 }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 }// 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 unifieddbSchema- The
basePathdetermines where your API is mounted (e.g.,/api/data/*) - The
adapterfunction receives this merged schema (db) and returns an adapter that translates BTST's database operations to your ORM - The
handleris 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.prismanpx @btst/cli generate --config=lib/stack.ts --orm=drizzle --output=src/db/schema.tsKysely 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.sqlOr using --database-url flag:
npx @btst/cli generate --config=lib/stack.ts --orm=kysely --output=migrations/schema.sql --database-url=sqlite:./dev.dbnpx @btst/cli generate --config=lib/stack.ts --orm=kysely --output=migrations/schema.sql --database-url=postgres://user:pass@localhost:5432/dbSee the CLI documentation for both:
npx @btst/codegen initproject scaffolding@btst/clischema 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.
import { toNextRouteHandlers } from "@btst/stack/next"
import { handler } from "@/lib/stack"
export const { GET, POST, PUT, PATCH, DELETE } = toNextRouteHandlers(handler)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.actionimport { 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:
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
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:
@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:
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:
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.
"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>
)
}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.
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>
)
}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.
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.generateMetadataimport { 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.Componentimport { 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:
createNextPageaccepts an asyncgetStackClient, plusnotFound,wrapPage, anddehydrateOptions.createReactRouterPageexposescreateLoader()for async request-aware stack clients and acceptsNotFound,ErrorBoundary,wrapPage, anddehydrateOptions.createTanStackPageOptionsacceptsgetLoaderStackClientfor async context-aware loaders andgetQueryClientwhen 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.generateMetadataReact 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.ComponentTanStack 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.
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()
}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",
},
})
}// 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: