diff --git a/.agents/skills/btst-build-config/SKILL.md b/.agents/skills/btst-build-config/SKILL.md
index 1ea77943..d2713be7 100644
--- a/.agents/skills/btst-build-config/SKILL.md
+++ b/.agents/skills/btst-build-config/SKILL.md
@@ -61,7 +61,8 @@ The `postbuild.cjs` script auto-discovers and copies them — no manual registra
## Updating all three codegen projects
-When adding a new plugin or changing plugin config, update ALL three:
+When adding a plugin, changing plugin config, or changing a framework entry
+point, update ALL three generated projects:
**Next.js** (`codegen-projects/nextjs/`)
- `lib/stack.ts` — backend plugin registration
@@ -81,6 +82,38 @@ When adding a new plugin or changing plugin config, update ALL three:
- `src/routes/pages/route.tsx`
- `src/styles.css`
+Keep generated framework routes on the v3 entry factories:
+
+| Framework | API route | Page route | Provider router |
+|---|---|---|---|
+| Next.js | `toNextRouteHandlers` | `createNextPage` | `nextRouter()` |
+| React Router | `toReactRouterHandlers` | `createReactRouterPage` | `reactRouter()` |
+| TanStack Start | `toTanStackHandlers` | `createTanStackPageOptions` | `tanstackRouter()` |
+
+Do not generate hand-written route resolution, loader/meta ordering,
+dehydration, or 404 plumbing. The entry factories own that behavior.
+
+Generated layouts must configure shared services once on `StackProvider`:
+
+```tsx
+
+ basePath="/pages"
+ router={frameworkRouter()}
+ api={{ baseURL, basePath: "/api/data" }}
+ auth={authProvider}
+ overrides={{
+ blog: { uploadImage },
+ }}
+>
+ {children}
+
+```
+
+Only plugin-specific values belong in `overrides`. Never generate `Link`,
+`Image`, `navigate`, `refresh`, API paths, identity, or login values inside a
+built-in plugin override. Client plugin factory fields such as `apiBaseURL`,
+`siteBaseURL`, and `queryClient` remain necessary for SSR loaders and metadata.
+
### Override type registration (in each layout)
```typescript
@@ -93,6 +126,9 @@ type PluginOverrides = {
}
```
+Register only the plugin's public override type. Do not recreate removed v2
+framework, API, guard, or identity fields in local intersection types.
+
## Adding shared UI components (@workspace/ui)
Components live in `packages/ui/src/components/`. Add via shadcn CLI:
@@ -122,3 +158,6 @@ pnpm turbo clean && pnpm build
- **Build cache** — run `pnpm turbo clean` if changes aren't reflected in codegen projects after `pnpm build`.
- **CSS not loading** — ensure `"./plugins/{name}/css"` entry exists in `package.json` exports; `postbuild.cjs` handles the rest automatically.
- **`@workspace/ui` sub-path components** — if a new component imports from a directory (not a single file), add it to `EXTERNAL_REGISTRY_COMPONENTS` in `build-registry.ts`.
+- **Stale v2 templates** — generated routes must use framework entry factories,
+ and generated layouts must keep shared `router`, `api`, and `auth` services at
+ the top level of `StackProvider`.
diff --git a/.agents/skills/btst-client-plugin-dev/EXAMPLES.md b/.agents/skills/btst-client-plugin-dev/EXAMPLES.md
index 7f2ad129..82e3081b 100644
--- a/.agents/skills/btst-client-plugin-dev/EXAMPLES.md
+++ b/.agents/skills/btst-client-plugin-dev/EXAMPLES.md
@@ -77,7 +77,7 @@ export function MyPage({ id }: { id: string }) {
## Client hooks (lifecycle) example
```typescript
-// In defineClientPlugin config:
+// In the config passed to myClientPlugin(config):
hooks: {
beforeLoadDetail: async (id, ctx) => {
const session = await getSession(ctx.headers)
diff --git a/.agents/skills/btst-client-plugin-dev/REFERENCE.md b/.agents/skills/btst-client-plugin-dev/REFERENCE.md
index b4e9754f..9ec01705 100644
--- a/.agents/skills/btst-client-plugin-dev/REFERENCE.md
+++ b/.agents/skills/btst-client-plugin-dev/REFERENCE.md
@@ -91,26 +91,34 @@ function createMyMeta(id: string, config: MyClientConfig) {
---
-## Query Keys Factory (query-keys.ts)
+## Resource declaration and query keys (query-keys.ts)
```typescript
-import { mergeQueryKeys, createQueryKeys } from "@lukemorales/query-key-factory"
-import { createApiClient } from "@btst/stack/client"
-import type { MyApiRouter } from "./api/plugin"
-
-export function createMyQueryKeys(client: ReturnType>, headers?: HeadersInit) {
- return mergeQueryKeys(
- createQueryKeys("myPlugin", {
- list: () => ({
- queryKey: ["list"],
- queryFn: async () => client.items.list({ headers }),
- }),
- detail: (id: string) => ({
- queryKey: [id],
- queryFn: async () => client.items.get(id, { headers }),
- }),
- })
- )
+import {
+ createResourceQueryKeys,
+ type ResourceClient,
+ type ResourcesDeclaration,
+} from "@btst/stack/plugins/client"
+
+export const myResources = {
+ items: {
+ queries: {
+ list: {
+ path: "/items",
+ select: (data: any) => data?.items ?? [],
+ },
+ detail: {
+ path: "/items",
+ query: (id: string) => ({ id }),
+ key: (id: string) => [id],
+ select: (data: any) => data?.item ?? null,
+ },
+ },
+ },
+} satisfies ResourcesDeclaration
+
+export function createMyQueryKeys(client: ResourceClient, headers?: HeadersInit) {
+ return createResourceQueryKeys(client, myResources, headers)
}
```
@@ -119,9 +127,25 @@ export function createMyQueryKeys(client: ReturnType
import("./components/pages/list-page").then(m => ({ default: m.ListPageComponent }))
)
@@ -129,29 +153,25 @@ const DetailPage = lazy(() =>
import("./components/pages/detail-page").then(m => ({ default: m.DetailPageComponent }))
)
-export const myClientPlugin = defineClientPlugin({
- name: "my-plugin",
- config: (overrides) => ({
- queryClient: overrides.queryClient,
- apiBaseURL: overrides.apiBaseURL,
- apiBasePath: overrides.apiBasePath,
- siteBaseURL: overrides.siteBaseURL,
- siteBasePath: overrides.siteBasePath,
- hooks: overrides.hooks,
- headers: overrides.headers,
- seo: overrides.seo,
- }),
- routes: (config) => ({
- list: createRoute("/my-plugin", () => ({
- PageComponent: () => ,
- loader: createListLoader(config),
- meta: createListMeta(config),
- })),
- detail: createRoute("/my-plugin/:id", ({ params }) => ({
- PageComponent: () => ,
- loader: createDetailLoader(params.id, config),
- meta: createDetailMeta(params.id, config),
- })),
- }),
-})
+export const myClientPlugin = (config: MyClientConfig) =>
+ defineClientPlugin({
+ name: "my-plugin",
+ routes: () =>
+ defineRoutes({
+ list: defineRoute("/my-plugin", {
+ page: ListPage,
+ loader: createListLoader(config),
+ meta: createListMeta(config),
+ }),
+ detail: defineRoute("/my-plugin/:id", {
+ page: ({ params }) => ,
+ loader: ({ params }) => createDetailLoader(params.id, config)(),
+ meta: ({ params }) => createDetailMeta(params.id, config)(),
+ }),
+ }),
+ })
```
+
+`MyClientConfig` is constructed in `getStackClient(queryClient)`. Do not
+derive it from `StackProvider` overrides: the plugin factory runs during SSR,
+while provider overrides are browser-runtime, plugin-specific customization.
diff --git a/.agents/skills/btst-client-plugin-dev/SKILL.md b/.agents/skills/btst-client-plugin-dev/SKILL.md
index 2831a2fb..f9f4008a 100644
--- a/.agents/skills/btst-client-plugin-dev/SKILL.md
+++ b/.agents/skills/btst-client-plugin-dev/SKILL.md
@@ -104,15 +104,16 @@ Rules:
## Route anatomy
-Each route returns exactly three things:
+Use `defineRoute` / `defineRoutes` for new routes. The `page`, `loader`, and
+`meta` handlers on a parameterized route each receive the route context:
```typescript
-routes: (config) => ({
- myRoute: createRoute("/path/:id", ({ params }) => ({
- PageComponent: () => ,
- loader: createMyLoader(params.id, config), // SSR only
- meta: createMyMeta(params.id, config), // SEO tags
- })),
+routes: () => defineRoutes({
+ myRoute: defineRoute("/path/:id", {
+ page: ({ params }) => ,
+ loader: ({ params }) => createMyLoader(params.id, config)(), // SSR only
+ meta: ({ params }) => createMyMeta(params.id, config)(), // SEO tags
+ }),
})
```
@@ -197,9 +198,18 @@ type PluginOverrides = {
}
```
+The client plugin factory still receives the QueryClient, absolute site/API
+URLs, optional SSR headers, SEO, and loader hooks because loaders and metadata
+run outside React Context. Keep that factory config independent from
+`StackProvider` overrides; never add a `config(overrides)` adapter that copies
+provider fields back into the plugin.
+
## Gotchas
- **Framework config in plugin overrides** — `Link`, `Image`, navigation, refresh, and client API paths come from the top-level `StackProvider`.
+- **Building plugin config from overrides** — plugin factory config is created
+ in `getStackClient(queryClient)`; provider overrides are browser-runtime
+ customization only.
- **`staleTime: Infinity`** — use for data that should not auto-refetch.
- **Next.js Link href undefined** — use `href={href || "#"}` pattern.
- **Suspense errors not caught** — add `if (error && !isFetching) throw error` in every suspense hook.
diff --git a/.agents/skills/btst-integration/REFERENCE.md b/.agents/skills/btst-integration/REFERENCE.md
index 7f49f307..cb4af870 100644
--- a/.agents/skills/btst-integration/REFERENCE.md
+++ b/.agents/skills/btst-integration/REFERENCE.md
@@ -70,44 +70,35 @@ export function getOrCreateQueryClient() {
**Next.js** (`app/api/data/[[...all]]/route.ts`):
```ts
-import { myStack } from "@/lib/stack"
+import { toNextRouteHandlers } from "@btst/stack/next"
+import { handler } from "@/lib/stack"
-export const { GET, POST, PUT, PATCH, DELETE } = myStack.handler
+export const { GET, POST, PUT, PATCH, DELETE } =
+ toNextRouteHandlers(handler)
```
-**React Router v7** (`app/routes/api.data.$.ts`):
+**React Router v7** (`app/routes/api/data/$.ts`):
```ts
-import { myStack } from "~/lib/stack"
-import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router"
+import { toReactRouterHandlers } from "@btst/stack/react-router"
+import { handler } from "~/lib/stack"
-export async function loader({ request }: LoaderFunctionArgs) {
- return myStack.handler(request)
-}
-export async function action({ request }: ActionFunctionArgs) {
- return myStack.handler(request)
-}
+const handlers = toReactRouterHandlers(handler)
+export const loader = handlers.loader
+export const action = handlers.action
```
**TanStack Start** (`src/routes/api/data/$.ts`):
```ts
import { createFileRoute } from "@tanstack/react-router"
-import { handler } from "~/lib/stack"
+import { toTanStackHandlers } from "@btst/stack/tanstack"
+import { handler } from "@/lib/stack"
export const Route = createFileRoute("/api/data/$")({
- server: {
- handlers: {
- GET: async ({ request }) => handler(request),
- POST: async ({ request }) => handler(request),
- PUT: async ({ request }) => handler(request),
- PATCH: async ({ request }) => handler(request),
- DELETE: async ({ request }) => handler(request),
- },
- },
+ server: { handlers: toTanStackHandlers(handler) },
})
```
-
---
## Pages catch-all route
@@ -115,38 +106,58 @@ export const Route = createFileRoute("/api/data/$")({
**Next.js** (`app/pages/[[...all]]/page.tsx`):
```tsx
-import { notFound } from "next/navigation"
-import { headers } from "next/headers"
-import { HydrationBoundary, dehydrate } from "@tanstack/react-query"
-import { normalizePath } from "@btst/stack/client"
+import { createNextPage } from "@btst/stack/next"
import { getOrCreateQueryClient } from "@/lib/query-client"
import { getStackClient } from "@/lib/stack-client"
-export default async function Page({ params }: { params: Promise<{ all?: string[] }> }) {
- const headersList = await headers()
- const headersObj = new Headers()
- headersList.forEach((value, key) => headersObj.set(key, value))
+export const dynamic = "force-dynamic"
+const page = createNextPage({
+ getStackClient,
+ getQueryClient: getOrCreateQueryClient,
+})
+export default page.Page
+export const generateMetadata = page.generateMetadata
+```
- const queryClient = getOrCreateQueryClient()
- const stackClient = getStackClient(queryClient, { headers: headersObj })
- const route = stackClient.router.getRoute(normalizePath((await params).all))
+**React Router v7** (`app/routes/pages/$.tsx`):
- if (!route) notFound()
- if (route.loader) await route.loader()
+```tsx
+import { createReactRouterPage } from "@btst/stack/react-router"
+import { getOrCreateQueryClient } from "~/lib/query-client"
+import { getStackClient } from "~/lib/stack-client"
- return (
-
-
-
- )
-}
+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
+```
+
+**TanStack Start** (`src/routes/pages/$.tsx`):
+
+```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 }),
+)
```
+The entry factories own route matching, loader-before-meta ordering,
+dehydration, and framework 404 behavior. Do not duplicate that plumbing in
+consumer routes.
+
---
## getBaseURL helper
-A server/client-safe URL helper — required for `apiBaseURL` in every plugin config and override.
+A server/client-safe URL helper for client plugin factory configuration and the
+top-level `StackProvider.api` service.
```ts
// Next.js
@@ -173,10 +184,7 @@ import { QueryClient } from "@tanstack/react-query"
const getBaseURL = () => /* see above */
-export const getStackClient = (
- queryClient: QueryClient,
- options?: { headers?: Headers }
-) => {
+export const getStackClient = (queryClient: QueryClient) => {
const baseURL = getBaseURL()
return createStackClient({
plugins: {
@@ -186,7 +194,6 @@ export const getStackClient = (
siteBaseURL: baseURL,
siteBasePath: "/pages",
queryClient,
- headers: options?.headers, // pass for SSR auth
seo: { siteName: "My App" }, // optional
hooks: { // optional client-side loader hooks
beforeLoadPost: async (slug, ctx) => { /* ... */ },
@@ -215,31 +222,38 @@ export const getStackClient = (
---
-## SSR headers forwarding (Next.js)
+## Auth wiring
-Pass request cookies/auth headers into the stack client during SSR so plugins can perform authenticated prefetches:
+Identity and client permissions belong on the top-level provider:
-```ts
-// app/pages/[[...all]]/page.tsx
-import { headers } from "next/headers"
-
-export default async function Page({ params }) {
- const headersList = await headers()
- const headersObj = new Headers()
- headersList.forEach((value, key) => headersObj.set(key, value))
+```tsx
+import type { StackAuthProvider } from "@btst/stack/context"
+
+const authProvider = {
+ getIdentity: async () => (await getSession())?.user ?? null,
+ loginPath: "/login",
+ can: ({ resource, action, identity }) =>
+ Boolean(identity && authorize(identity, resource, action)),
+} satisfies StackAuthProvider
+
+
+ {children}
+
+```
- const queryClient = getOrCreateQueryClient()
- const stackClient = getStackClient(queryClient, { headers: headersObj })
- const route = stackClient.router.getRoute(normalizePath((await params).all))
+Configure server identity separately on `stack({ auth })` and enforce
+authorization in backend lifecycle hooks. Do not pass `currentUserId`,
+`loginHref`, request headers, API paths, or navigation functions to public
+plugin components.
- if (route?.loader) await route.loader()
- return (
-
- {route?.PageComponent ? : notFound()}
-
- )
-}
-```
+Optional `headers` fields declared by a client plugin belong in that plugin's
+factory config for SSR loader hooks; they are not provider overrides or
+component identity props.
---
@@ -248,7 +262,7 @@ export default async function Page({ params }) {
The pages layout must be `"use client"` and wrap `QueryClientProvider` then `StackProvider`.
```tsx
-// Next.js: app/pages/layout.tsx (or app/pages/[[...all]]/layout.tsx)
+// Next.js: app/pages/layout.tsx
"use client"
import { useState } from "react"
import { QueryClientProvider } from "@tanstack/react-query"
diff --git a/.agents/skills/btst-integration/SKILL.md b/.agents/skills/btst-integration/SKILL.md
index c677cfd6..5b181f53 100644
--- a/.agents/skills/btst-integration/SKILL.md
+++ b/.agents/skills/btst-integration/SKILL.md
@@ -10,9 +10,10 @@ description: Guides developers and AI agents through manual BTST library consump
1. Install `@btst/stack`, `@tanstack/react-query`, and one `@btst/adapter-*`.
2. Install and register each plugin's backend and client halves.
3. Create `lib/stack.ts` → export `{ handler, dbSchema }`.
-4. Mount a catch-all API route at `/api/data/*` forwarding all methods to `handler`.
+4. Mount `handler` with the framework API entry factory at `/api/data/*`.
5. Add `@import "@btst/stack/plugins/{plugin}/css"` per plugin in your global CSS.
-6. Create `lib/stack-client.tsx`, `lib/query-client.ts`, and the `/pages/*` catch-all route.
+6. Create `lib/stack-client.tsx`, `lib/query-client.ts`, and the `/pages/*`
+ catch-all route with the framework page entry factory.
7. Create the pages **layout** file with `QueryClientProvider` + `StackProvider`.
8. Run `@btst/cli generate` (and `migrate` for Kysely).
@@ -61,8 +62,12 @@ Do not duplicate — the patcher and manual edits must both be idempotent.
### 5) Wire framework routes and client runtime
-- **API route**: catch-all at `/api/data/*`, forward GET/POST/PUT/PATCH/DELETE to `handler`.
-- **Pages route**: catch-all at `/pages/*` — resolve via `stackClient.router.getRoute(path)`, run `route.loader?.()` server-side, wrap in `HydrationBoundary`.
+- **API route**: use `toNextRouteHandlers`,
+ `toReactRouterHandlers`, or `toTanStackHandlers` from the framework
+ entry point.
+- **Pages route**: use `createNextPage`,
+ `createReactRouterPage`, or `createTanStackPageOptions`. The factory
+ owns route matching, loader ordering, hydration, metadata, and 404 handling.
- **Pages layout** (`"use client"` in Next.js): wrap in `QueryClientProvider` then `StackProvider`:
- `basePath="/pages"` (must match your pages catch-all prefix)
- `router={nextRouter()}` / `reactRouter()` / `tanstackRouter()` for framework-wide links, images, navigation, and refresh.
@@ -95,6 +100,7 @@ Do not duplicate — the patcher and manual edits must both be idempotent.
- `stack.ts` exports both `handler` and `dbSchema`.
- Every plugin is registered on both backend and client sides.
- API `basePath` and `stack({ basePath })` match exactly.
+- API and page catch-all routes use the framework entry factories.
- Pages layout is `"use client"` and wraps `QueryClientProvider` then `StackProvider`.
- `StackProvider` `basePath` matches the `/pages` catch-all route prefix.
- Global CSS has one `@import` line per selected plugin.
@@ -110,4 +116,6 @@ Do not duplicate — the patcher and manual edits must both be idempotent.
- **Memory adapter + Next.js** — always pin to `globalThis` to share one in-memory store across API and page bundles.
- **Path aliases in CLI** — `@btst/cli` executes your config file directly; use relative imports in `lib/stack.ts` and its dependencies.
- **Kysely generate needs DB** — pass `DATABASE_URL` or `--database-url`; use `dotenv-cli` for `.env.local`.
-- **SSR headers for auth** — forward `await headers()` (Next.js) into `getStackClient(queryClient, { headers })` so plugins can read cookies/auth tokens during SSR.
+- **Provider services copied into plugin overrides** — never add `Link`,
+ `Image`, navigation, refresh, API paths, identity, or login values to
+ built-in plugin overrides. Use top-level `router`, `api`, and `auth`.
diff --git a/README.md b/README.md
index 7a1ab7dc..1cb0856f 100644
--- a/README.md
+++ b/README.md
@@ -62,7 +62,7 @@ You keep your codebase, database, and deployment.
---
-## Minimal usage
+## Minimal setup (Next.js)
```ts title="lib/stack.ts"
import { stack } from "@btst/stack"
@@ -97,7 +97,46 @@ export const getStackClient = (queryClient: QueryClient) =>
})
```
-Now you have a working blog with API, pages, SSR, and SEO. See the [full installation guide](https://www.better-stack.ai/docs/installation) for database adapters, auth hooks, and framework-specific setup.
+Use the v3 framework entry factories for the two catch-all routes:
+
+```ts title="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)
+```
+
+```tsx title="app/pages/[[...all]]/page.tsx"
+import { createNextPage } from "@btst/stack/next"
+import { getStackClient } from "@/lib/stack-client"
+import { getOrCreateQueryClient } from "@/lib/query-client"
+
+const page = createNextPage({
+ getStackClient,
+ getQueryClient: getOrCreateQueryClient,
+})
+export default page.Page
+export const generateMetadata = page.generateMetadata
+```
+
+Wrap the pages subtree with one `StackProvider`:
+
+```tsx
+
+ {children}
+
+```
+
+Router, API, and auth services belong at the top level; plugin overrides contain
+only plugin-specific customization. See the [full installation guide](https://www.better-stack.ai/docs/installation)
+for QueryClient wiring, database adapters, all three frameworks, and auth.
## Database schemas & migrations
diff --git a/docs/content/docs/breaking-changes.mdx b/docs/content/docs/breaking-changes.mdx
index 811be58d..ed1b15a0 100644
--- a/docs/content/docs/breaking-changes.mdx
+++ b/docs/content/docs/breaking-changes.mdx
@@ -10,101 +10,171 @@ This page documents breaking changes between major versions and provides migrati
---
-## v2 → v3: Single provider wiring path
+## v2 → v3: Provider-only framework integration
-BTST v3 removes the per-plugin compatibility path for framework wiring. Configure router, API, and auth once on `StackProvider`; plugin overrides now contain only plugin-specific customization.
+BTST v3 has one supported framework-wiring path: framework entry factories
+own the catch-all routes, while `StackProvider` owns browser-side router, API,
+and auth services. Plugin factories still own server-loader and metadata
+configuration; plugin overrides contain only plugin-specific customization.
+
+
+ v3 removes the v2 compatibility fallbacks. Complete every step in this
+ section before upgrading.
+
+
+### 1. Replace hand-written catch-all routes with entry factories
+
+Replace copied API-handler and page-rendering glue with the matching framework
+entry point. For Next.js, the migration is:
+
+```diff
++ import { toNextRouteHandlers } from "@btst/stack/next"
+ import { handler } from "@/lib/stack"
+
+- export const GET = handler
+- export const POST = handler
+- export const PUT = handler
+- export const PATCH = handler
+- export const DELETE = handler
++ export const { GET, POST, PUT, PATCH, DELETE } =
++ toNextRouteHandlers(handler)
+```
```diff
- router.push(path),
-- refresh: () => router.refresh(),
-- Link,
-- Image,
- uploadImage,
- },
- }}
->
++ import { createNextPage } from "@btst/stack/next"
+ import { getStackClient } from "@/lib/stack-client"
+ import { getOrCreateQueryClient } from "@/lib/query-client"
+
+- export default async function Page({ params }) {
+- // normalize the path, resolve the route, run its loader,
+- // dehydrate React Query, render the page, and handle 404
+- }
+- export async function generateMetadata({ params }) {
+- // resolve the route, run its loader, and convert metadata
+- }
++ const page = createNextPage({
++ getStackClient,
++ getQueryClient: getOrCreateQueryClient,
++ })
++ export default page.Page
++ export const generateMetadata = page.generateMetadata
```
-The `Link`, `Image`, `navigate`, `refresh`, `apiBaseURL`, and `apiBasePath` fields were removed from built-in plugin override types. The `apiBaseURL` and `apiBasePath` fields on each client plugin factory remain required because SSR loaders run outside React Context.
+Use the equivalent pair for your framework:
+
+| Framework | API factory | Page factory | Router preset |
+| --- | --- | --- | --- |
+| Next.js | `toNextRouteHandlers` | `createNextPage` | `nextRouter` |
+| React Router | `toReactRouterHandlers` | `createReactRouterPage` | `reactRouter` |
+| TanStack Router | `toTanStackHandlers` | `createTanStackPageOptions` | `tanstackRouter` |
-### Comments use provider API and identity
+The [installation guide](/installation) has complete route files for all three
+frameworks.
-`CommentThread`, `CommentCount`, moderation pages, and user-comments pages no longer accept manual API, header, identity, or login props. Render them under the configured provider:
+### 2. Move shared services to `StackProvider`
+
+Remove framework and API fields from every built-in plugin override. Configure
+them once at the top level:
```diff
-
++ import { nextRouter } from "@btst/stack/next"
+
+ router.push(path),
+- refresh: () => router.refresh(),
+- Link,
+- Image,
+ uploadImage,
+ },
+ }}
+ >
```
-### Route authorization uses `StackProvider.auth`
+`Link`, `Image`, `navigate`, `refresh`, `apiBaseURL`, and
+`apiBasePath` were removed from built-in plugin override types. There is no
+per-plugin precedence or fallback in v3.
-The `onBefore*PageRendered` override callbacks were removed. They only controlled client rendering and were never an API authorization boundary. Built-in protected routes now declare resource/action permissions through `ComposedRoute`; implement `auth.can` once for client-side gating:
+Client plugin factories still require `apiBaseURL`, `apiBasePath`,
+`siteBaseURL`, `siteBasePath`, and `queryClient` where declared. Their SSR
+loaders and metadata run outside React Context:
```tsx
-const authProvider: StackAuthProvider = {
- getIdentity,
- loginPath: "/login",
- can: ({ resource, action, identity }) => {
- if (!identity) return false
- if (resource === "comments:comment" && action === "moderate") {
- return identity.role === "moderator" || identity.role === "admin"
- }
- return true
- },
-}
+blogClientPlugin({
+ apiBaseURL: baseURL,
+ apiBasePath: "/api/data",
+ siteBaseURL: baseURL,
+ siteBasePath: "/pages",
+ queryClient,
+})
```
-For Comments, the moderation route requests `comments:comment` / `moderate`; per-resource moderation and delete controls use the same provider permissions. The user-comments page and `CommentThread` resolve the current user through `auth.getIdentity` and show their login state when no identity is available. Keep SSR checks in client loader hooks and enforce authorization again in backend lifecycle hooks.
+### 3. Replace render guards with `StackProvider.auth`
-### Backend hooks deny by throwing
-
-The compatibility shim for boolean lifecycle-hook returns was removed. Returning `false` no longer denies a request; throw an `Error` from an `onBefore*` hook instead. This includes Form Builder's `onBeforeSubmission` hook:
+All `onBefore*PageRendered` override callbacks were removed. Move client-side
+route decisions into one permission provider:
```diff
-onBeforeSubmission: async (_formSlug, data, ctx) => {
-- if (!ctx.headers.get("x-user-id")) return false
-+ if (!ctx.headers.get("x-user-id")) throw new Error("Unauthorized")
- return data
-}
+ {
++ if (!identity) return false
++ return authorize(identity, resource, action)
++ },
++ }}
+ overrides={{
+ blog: {
+- onBeforeDraftsPageRendered: () => Boolean(currentUser),
+- onBeforeNewPostPageRendered: () => currentUser?.role === "admin",
+ uploadImage,
+ },
+ }}
+ >
```
-Hooks that transform input continue to return the transformed object.
+Built-in routes declare their resource/action requirements. The auth provider
+gates client navigation and controls; backend lifecycle hooks remain the
+authoritative security boundary.
----
+### 4. Remove manual API and identity component props
-## v2 → v3: Declarative routes and the `pageComponents` contract
+`CommentThread` and `CommentCount` now read API and identity services from the
+nearest provider:
-BTST v3 adopts the declarative `defineRoute` / `defineRoutes` helpers from `@btst/yar` 1.3+ for all plugin routes, and stabilizes page component identity across re-renders (no more subtree remounts or Suspense re-triggers on parent renders).
+```diff
+
+```
-
- This section covers the route-definition migration. Overrides for routes without params are unchanged.
-
+Keep `headers` only in client plugin factory configuration when an SSR loader
+hook needs request headers; it is no longer a public component identity prop.
-### `pageComponents` overrides now receive the route context
+### 5. Update parameterized page-component overrides
-Overrides for parameterized routes used to receive specific named props (e.g. `slug`, `boardId`, `conversationId`). They now receive the route context — `{ params }` — instead:
+Parameterized `pageComponents` now receive the declarative route context
+`{ params }` instead of named props:
```diff
blogClientPlugin({
- // ... other config
+ // ...server-loader and metadata config
pageComponents: {
- posts: MyCustomPostsPage, // unchanged (no params)
+ posts: MyCustomPostsPage,
- post: ({ slug }) => ,
+ post: ({ params }) => ,
- tag: ({ tagSlug }) => ,
@@ -113,10 +183,8 @@ blogClientPlugin({
})
```
-Renamed props per plugin:
-
-| Plugin | Override | Old props | New props |
-|--------|----------|-----------|-----------|
+| Plugin | Override | v2 props | v3 props |
+| --- | --- | --- | --- |
| Blog | `post`, `editPost` | `{ slug }` | `{ params: { slug } }` |
| Blog | `tag` | `{ tagSlug }` | `{ params: { tagSlug } }` |
| CMS | `contentList`, `newContent` | `{ typeSlug }` | `{ params: { typeSlug } }` |
@@ -127,27 +195,51 @@ Renamed props per plugin:
| Kanban | `board` | `{ boardId }` | `{ params: { boardId } }` |
| AI Chat | `chatConversation` | `{ conversationId }` | `{ params: { id } }` |
-### Custom plugins: `defineRoute` is the recommended route helper
+Routes without parameters keep their existing component contract. Custom
+plugins should use `defineRoute` / `defineRoutes` from
+`@btst/stack/plugins/client`.
+
+### 6. Deny backend hooks by throwing
-`createRoute` is still exported and fully supported, but plugin routes are simpler with `defineRoute`:
+The boolean-return compatibility shim was removed. Returning `false` no longer
+denies a request:
```diff
-- todos: createRoute("/todos", () => ({
-- PageComponent: TodosListPage,
-- loader: todosLoader(config),
-- meta: createTodosMeta(config, "/todos"),
-- })),
-+ todos: defineRoute("/todos", {
-+ page: TodosListPage,
-+ loader: todosLoader(config),
-+ meta: createTodosMeta(config, "/todos"),
-+ }),
+onBeforeSubmission: async (_formSlug, data, ctx) => {
+- if (!ctx.headers.get("x-user-id")) return false
++ if (!ctx.headers.get("x-user-id")) throw new Error("Unauthorized")
+ return data
+}
```
-`defineRoute`, `defineRoutes`, and the `RouteContext` / `RouteDef` types are re-exported from `@btst/stack/plugins/client`.
+Hooks that transform input continue to return the transformed value.
----
+Older Form Builder documentation showed the unsupported
+`onBeforeDeleteSubmission` name. v3 exposes
+`onBeforeSubmissionDeleted`; correct the hook name while updating its denial
+behavior:
+```diff
+- onBeforeDeleteSubmission: async (submissionId, ctx) => {
+- if (!isAdmin(ctx.headers)) return false
++ onBeforeSubmissionDeleted: async (submissionId, ctx) => {
++ if (!isAdmin(ctx.headers)) throw new Error("Admin access required")
+ }
+```
+
+### Migration checklist
+
+- Replace API and page catch-all glue with the framework entry factories.
+- Add one framework router preset and API configuration to `StackProvider`.
+- Add `StackProvider.auth` when routes or controls require identity or permissions.
+- Delete shared router/API fields and `onBefore*PageRendered` callbacks from plugin overrides.
+- Remove API, header, identity, and login props from Comments components.
+- Update parameterized `pageComponents` adapters to read `params`.
+- Change backend hook denials from boolean returns to thrown errors.
+- Replace Form Builder's stale documented `onBeforeDeleteSubmission` name with `onBeforeSubmissionDeleted`.
+- Run your framework build, TypeScript checks, and tests.
+
+---
## v1 → v2: Rebranding to BTST
BTST v2 introduces a rebranding from "Better Stack" to "BTST". This guide covers all the changes you need to make to upgrade your project.
diff --git a/docs/content/docs/cli.mdx b/docs/content/docs/cli.mdx
index 6f613861..f2c6f38c 100644
--- a/docs/content/docs/cli.mdx
+++ b/docs/content/docs/cli.mdx
@@ -38,14 +38,19 @@ Common flags:
- `lib/stack.ts` (or framework equivalent)
- `lib/stack-client.tsx`
- `lib/query-client.ts`
-- API catch-all route and pages catch-all route
+- API and pages catch-all routes using the framework entry factories
- Global CSS imports (including plugin CSS)
-- Root layout with `QueryClientProvider` where possible
+- Pages layout with `QueryClientProvider` and one top-level
+ `StackProvider.router` / `StackProvider.api` configuration
If root layout patching is not safe for your file shape, the command prints manual patch instructions instead of applying a destructive rewrite.
Generated plugin config entries in `lib/stack.ts` and `lib/stack-client.tsx` use camelCase config keys (for example `aiChat`, `uiBuilder`, `formBuilder`) even though plugin selection flags use kebab-case names.
+Generated v3 layouts never repeat framework router, API, or identity wiring in
+plugin overrides. Replace only the plugin-specific TODO values (for example an
+upload function or user resolver).
+
## Generate and Migrate via Codegen
If you prefer one command surface, these delegate to `@btst/cli`:
@@ -177,4 +182,4 @@ or using dotenv-cli:
```bash
npx dotenv-cli -e .env.local -- npx @btst/cli generate --orm drizzle --config lib/stack.ts --output db/btst-schema.ts
-```
\ No newline at end of file
+```
diff --git a/docs/content/docs/how-it-works.mdx b/docs/content/docs/how-it-works.mdx
index b51257e9..7a0c161e 100644
--- a/docs/content/docs/how-it-works.mdx
+++ b/docs/content/docs/how-it-works.mdx
@@ -16,7 +16,7 @@ The server handles database operations, API endpoints, data prefetching, routing
- **API Router**: Routes incoming requests to the appropriate plugin handlers. Returns a handler function that you mount at your API path.
- **DB Adapter**: Translates BTST's database operations to your ORM (Prisma, Drizzle, Kysely, MongoDB). Plugins define schemas that get merged and passed to the adapter.
-**`stackClient`** manages the rendering layer:
+**`createStackClient`** manages the rendering layer:
- **Data Fetching**: Plugins can prefetch data server-side into React Query cache before rendering, enabling instant page loads with hydrated state.
- **Page Router**: Matches URLs to plugin routes and returns the appropriate page component, loader, and metadata.
@@ -32,7 +32,9 @@ Server-rendered HTML is hydrated with client-side React. The React Query cache
**SPA Navigation (If using in an SPA)**
-After the initial page load, `stackClient`'s router handles client-side navigation. Clicking links doesn't trigger full page reloads—React Query fetches data in the background while the UI updates immediately.
+After the initial page load, the framework router preset on `StackProvider`
+handles client-side navigation. React Query fetches data in the background
+while the UI updates.
**State Management**
@@ -80,7 +82,7 @@ const stackClient = createStackClient({
- Register API route handlers for CRUD operations
- Provide hooks for authorization and custom logic
-**Client plugins** (registered in `stackClient`):
+**Client plugins** (registered in `createStackClient`):
- Define page routes and components
- Provide loaders for server-side data prefetching
- Export components, hooks, and utilities for state management
@@ -88,11 +90,18 @@ const stackClient = createStackClient({
This separation keeps server-only code (database schemas, API handlers) out of your client bundle, and allows each plugin to be configured independently for its context.
-## Overrides
+## Provider Services and Plugin Overrides
-Framework-specific components injected into plugins at runtime:
+Framework services are configured once on `StackProvider`:
-- **Link**: Use Next.js `Link`, React Router `Link`, or TanStack `Link` for optimized navigation
-- **Image**: Use Next.js `Image` for automatic optimization
-- **navigate**: Programmatic navigation function for your framework
-- **apiBaseURL/apiBasePath**: Configure where your API is mounted
+- **`router`**: Use `nextRouter()`, `reactRouter()`, or
+ `tanstackRouter()` for links, images, navigation, refresh, and URL search
+ state.
+- **`api`**: Configure the browser-side API `baseURL` and `basePath`.
+- **`auth`**: Resolve identity, provide a login path, and authorize
+ resource/action checks.
+
+The `overrides` object is only for plugin-specific customization such as
+upload functions, component slots, localization, and route analytics. SSR
+loader and metadata values remain in each client plugin factory because those
+functions run outside React Context.
diff --git a/docs/content/docs/installation.mdx b/docs/content/docs/installation.mdx
index b236b83a..ef173b45 100644
--- a/docs/content/docs/installation.mdx
+++ b/docs/content/docs/installation.mdx
@@ -6,7 +6,6 @@ description: Learn how to install and configure BTST in your project.
import { Steps, Step } from "fumadocs-ui/components/steps";
import { Tabs, Tab } from "fumadocs-ui/components/tabs";
import { Callout } from "fumadocs-ui/components/callout";
-import { Accordions, Accordion } from "fumadocs-ui/components/accordion";
## AI Agent Skills
@@ -435,59 +434,8 @@ In order to use BTST, your application must meet the following requirements:
-
-
- The helpers only wire your `handler` to each HTTP method — hand-writing the glue remains fully supported if you need custom behavior (auth wrappers, logging, filtering methods):
-
-
-
- ```ts title="app/api/data/[[...all]]/route.ts"
- import { handler } from "@/lib/stack"
-
- export const GET = handler
- export const POST = handler
- export const PUT = handler
- export const PATCH = handler
- export const DELETE = handler
- ```
-
-
-
- ```ts title="app/routes/api/data/$.ts"
- import type { Route } from "./+types/$"
- import { handler } from "~/lib/stack"
-
- export function loader({ request }: Route.LoaderArgs) {
- return handler(request)
- }
-
- export function action({ request }: Route.ActionArgs) {
- return handler(request)
- }
- ```
-
-
-
- ```ts title="src/routes/api/data/$.ts"
- import { createFileRoute } from '@tanstack/react-router'
- import { handler } from '@/lib/stack'
-
- export const Route = createFileRoute('/api/data/$')({
- server: {
- handlers: {
- GET: async ({ request }) => handler(request),
- POST: async ({ request }) => handler(request),
- PUT: async ({ request }) => handler(request),
- PATCH: async ({ request }) => handler(request),
- DELETE: async ({ request }) => handler(request),
- },
- },
- })
- ```
-
-
-
-
+ Keep the API path in this route, `stack({ basePath })`, and
+ `StackProvider.api.basePath` identical.
@@ -510,14 +458,26 @@ In order to use BTST, your application must meet the following requirements:
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:
- ```ts title="lib/stack-client.tsx"
+ ```tsx title="lib/stack-client.tsx"
import { createStackClient } from "@btst/stack/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"
+ ? process.env.BASE_URL || "http://localhost:3000"
+ : window.location.origin
+
return createStackClient({
plugins: {
- // Add your client plugins here
+ blog: blogClientPlugin({
+ apiBaseURL: baseURL,
+ apiBasePath: "/api/data",
+ siteBaseURL: baseURL,
+ siteBasePath: "/pages",
+ queryClient,
+ }),
}
})
}
@@ -527,14 +487,15 @@ In order to use BTST, your application must meet the following requirements:
**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.
+ Client plugin factories keep URL and QueryClient configuration because their
+ SSR loaders and metadata run outside React Context. Browser-side routing,
+ API calls, identity, and permissions come from `StackProvider` in the
+ layout step below; do not copy those services into plugin overrides.
- ### Set Up Query Client Provider
+ ### Create the Query Client
If you don't already have a query client utility, create one to ensure proper SSR hydration:
@@ -579,89 +540,8 @@ In order to use BTST, your application must meet the following requirements:
}
```
- Then configure `QueryClientProvider` in your your app:
-
-
-
- ```tsx title="app/layout.tsx"
- import { QueryClientProvider } from "@tanstack/react-query"
- import { getOrCreateQueryClient } from "@/lib/query-client"
-
- export default function RootLayout({ children }) {
- const queryClient = getOrCreateQueryClient()
-
- return (
-
-
-
- {children}
-
-
-
- )
- }
- ```
-
-
-
- ```tsx title="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 (
-
-
-
- )
- }
- ```
-
-
-
- ```tsx title="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
- }
- }
- ```
-
-
+ The framework layouts in the next step install `QueryClientProvider`
+ alongside `StackProvider`, so BTST pages have one provider boundary.
The `getOrCreateQueryClient()` utility ensures:
@@ -675,44 +555,48 @@ In order to use BTST, your application must meet the following requirements:
- ### Set Up Layout Provider
+ ### Set Up the Provider Layout
- 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:
+ Put React Query and BTST's framework services around the `/pages/*`
+ subtree. Router, API, and auth are top-level services shared by every
+ plugin. The `overrides` object contains only plugin-specific UI or
+ behavior such as Blog's upload function.
- ```tsx title="app/pages/[[...all]]/layout.tsx"
+ ```tsx title="app/pages/layout.tsx"
"use client"
+
+ import { useState } from "react"
+ import { QueryClientProvider } from "@tanstack/react-query"
import { StackProvider } from "@btst/stack/context"
import { nextRouter } from "@btst/stack/next"
- import type { ExamplePluginOverrides } from "@btst/stack/plugins/example/client"
+ import type { BlogPluginOverrides } from "@btst/stack/plugins/blog/client"
+ import { getOrCreateQueryClient } from "@/lib/query-client"
+ import { uploadImage } from "@/lib/uploads"
- // Define the shape of all plugin overrides for type safety
type PluginOverrides = {
- example: ExamplePluginOverrides
- // Add other plugins here
+ blog: BlogPluginOverrides
}
- const getBaseURL = () =>
- typeof window !== "undefined"
- ? window.location.origin
- : process.env.BASE_URL || "http://localhost:3000"
+ export default function PagesLayout({ children }) {
+ const [queryClient] = useState(() => getOrCreateQueryClient())
+ const baseURL =
+ typeof window !== "undefined"
+ ? window.location.origin
+ : process.env.BASE_URL || "http://localhost:3000"
- export default function Layout({ children }) {
return (
-
- basePath="/pages"
- router={nextRouter()}
- api={{ baseURL: getBaseURL(), basePath: "/api/data" }}
- overrides={{
- example: {
- // Only plugin-specific overrides needed here
- }
- // Add other plugins here
- }}
- >
- {children}
-
+
+
+ basePath="/pages"
+ router={nextRouter()}
+ api={{ baseURL, basePath: "/api/data" }}
+ overrides={{ blog: { uploadImage } }}
+ >
+ {children}
+
+
)
}
```
@@ -720,37 +604,37 @@ In order to use BTST, your application must meet the following requirements:
```tsx title="app/routes/pages/_layout.tsx"
+ import { useState } from "react"
import { Outlet } from "react-router"
+ import { QueryClientProvider } from "@tanstack/react-query"
import { StackProvider } from "@btst/stack/context"
import { reactRouter } from "@btst/stack/react-router"
- import type { ExamplePluginOverrides } from "@btst/stack/plugins/example/client"
+ import type { BlogPluginOverrides } from "@btst/stack/plugins/blog/client"
+ import { getOrCreateQueryClient } from "~/lib/query-client"
+ import { uploadImage } from "~/lib/uploads"
- // Define the shape of all plugin overrides
type PluginOverrides = {
- example: ExamplePluginOverrides
- // Add other plugins here
+ blog: BlogPluginOverrides
}
- const getBaseURL = () =>
- typeof window !== "undefined"
- ? window.location.origin
- : process.env.BASE_URL || "http://localhost:3000"
+ export default function PagesLayout() {
+ const [queryClient] = useState(() => getOrCreateQueryClient())
+ const baseURL =
+ typeof window !== "undefined"
+ ? window.location.origin
+ : process.env.BASE_URL || "http://localhost:5173"
- export default function Layout() {
return (
-
- basePath="/pages"
- router={reactRouter()}
- api={{ baseURL: getBaseURL(), basePath: "/api/data" }}
- overrides={{
- example: {
- // Only plugin-specific overrides needed here
- }
- // Add other plugins here
- }}
- >
-
-
+
+
+ basePath="/pages"
+ router={reactRouter()}
+ api={{ baseURL, basePath: "/api/data" }}
+ overrides={{ blog: { uploadImage } }}
+ >
+
+
+
)
}
```
@@ -758,42 +642,35 @@ In order to use BTST, your application must meet the following requirements:
```tsx title="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 { QueryClientProvider } from "@tanstack/react-query"
- import type { ExamplePluginOverrides } from "@btst/stack/plugins/example/client"
- import { Outlet, createFileRoute } from "@tanstack/react-router"
+ import type { BlogPluginOverrides } from "@btst/stack/plugins/blog/client"
+ import { uploadImage } from "@/lib/uploads"
- // Define the shape of all plugin overrides
type PluginOverrides = {
- example: ExamplePluginOverrides
- // Add other plugins here
+ blog: BlogPluginOverrides
}
- const getBaseURL = () =>
- typeof window !== "undefined"
- ? window.location.origin
- : process.env.BASE_URL || "http://localhost:3000"
-
- export const Route = createFileRoute('/pages')({
- component: Layout
+ export const Route = createFileRoute("/pages")({
+ component: PagesLayout,
})
- function Layout() {
- const context = Route.useRouteContext()
+ function PagesLayout() {
+ const { queryClient } = Route.useRouteContext()
+ const baseURL =
+ typeof window !== "undefined"
+ ? window.location.origin
+ : process.env.BASE_URL || "http://localhost:3000"
return (
-
+
basePath="/pages"
router={tanstackRouter()}
- api={{ baseURL: getBaseURL(), basePath: "/api/data" }}
- overrides={{
- example: {
- // Only plugin-specific overrides needed here
- }
- // Add other plugins here
- }}
+ api={{ baseURL, basePath: "/api/data" }}
+ overrides={{ blog: { uploadImage } }}
>
@@ -804,12 +681,9 @@ In order to use BTST, your application must meet the following requirements:
-
- **Understanding Overrides:**
- - **Framework wiring**: `router` supplies `Link`, `Image`, navigation, and refresh to every plugin; `api` supplies the client API location.
- - **Purpose**: `overrides` contains only plugin-specific customization such as upload handlers, component slots, localization, and route analytics hooks.
- - **Type Safety**: Each plugin exports its override type (e.g., `ExamplePluginOverrides`)
-
+ Add `auth`, `notify`, or `i18n` beside `router` and `api` when your
+ application needs them. See the [auth provider guide](/auth). Never copy
+ framework routing, API paths, or identity props into a plugin override.
@@ -864,163 +738,14 @@ In order to use BTST, your application must meet the following requirements:
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
-
-
-
-
- `stackClient.router.getRoute(path)` matches the URL to a plugin route and returns a route object:
-
- ```typescript
- route = {
- PageComponent: React.ComponentType, // The page to render
- loader?: () => Promise, // Prefetches React Query data
- meta?: () => MetadataElements, // Returns SEO metadata
- ErrorComponent?: React.ComponentType, // Standalone error components
- LoadingComponent?: React.ComponentType // Standalone loading components
- }
- ```
-
- If you need behavior the factories don't cover, write the catch-all route yourself:
-
-
-
- ```tsx title="app/pages/[[...all]]/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 { metaElementsToObject, normalizePath } from "@btst/stack/client"
- import { Metadata } from "next"
-
- export default async function Page({ params }: { params: Promise<{ all: string[] }> }) {
- const pathParams = await params
- const path = normalizePath(pathParams?.all)
-
- const queryClient = getOrCreateQueryClient()
- const stackClient = getStackClient(queryClient)
- const route = stackClient.router.getRoute(path)
-
- // Prefetch data server-side if the route has a loader
- if (route?.loader) await route.loader()
-
- // Serialize React Query cache for client hydration
- const dehydratedState = dehydrate(queryClient)
-
- return (
-
- {route && route.PageComponent ? : notFound()}
-
- )
- }
-
- export async function generateMetadata({ params }: { params: Promise<{ all: string[] }> }) {
- const pathParams = await params
- const path = normalizePath(pathParams?.all)
-
- const queryClient = getOrCreateQueryClient()
- const stackClient = getStackClient(queryClient)
- const route = stackClient.router.getRoute(path)
-
- if (!route) return notFound()
- if (route?.loader) await route.loader()
-
- // Convert plugin meta elements to Next.js Metadata format
- return route.meta ? metaElementsToObject(route.meta()) satisfies Metadata : { title: "No meta" }
- }
- ```
-
+ **Factory options:**
+ - `createNextPage` accepts `notFound`, `wrapPage`, and `dehydrateOptions`.
+ - `createReactRouterPage` accepts `NotFound`, `ErrorBoundary`, `wrapPage`, and `dehydrateOptions`.
+ - `createTanStackPageOptions` accepts `getQueryClient` when the QueryClient is not available from router context.
-
- ```tsx title="app/routes/pages/$.tsx"
- import type { Route } from "./+types/$"
- import { useLoaderData } from "react-router"
- import { dehydrate, HydrationBoundary, useQueryClient } from "@tanstack/react-query"
- import { getOrCreateQueryClient } from "~/lib/query-client"
- import { getStackClient } from "~/lib/stack-client"
- import { normalizePath } from "@btst/stack/client"
-
- export async function loader({ params }: Route.LoaderArgs) {
- const queryClient = getOrCreateQueryClient()
- const path = normalizePath(params["*"])
- const route = getStackClient(queryClient).router.getRoute(path)
-
- if (route?.loader) await route.loader()
-
- // Include errors so client doesn't refetch on error
- const dehydratedState = dehydrate(queryClient)
-
- return { path, dehydratedState, meta: route?.meta?.() }
- }
-
- export function meta({ loaderData }: Route.MetaArgs) {
- return loaderData.meta
- }
-
- export default function PagesIndex() {
- const { path, dehydratedState } = useLoaderData()
- const queryClient = useQueryClient()
- const route = getStackClient(queryClient).router.getRoute(path)
- const Page = route && route.PageComponent ? : Route not found
-
- return dehydratedState ? (
- {Page}
- ) : Page
- }
- ```
-
-
-
- ```tsx title="src/routes/pages/$.tsx"
- import { createFileRoute, notFound } from "@tanstack/react-router"
- import { getStackClient } from "@/lib/stack-client"
- import { normalizePath } from "@btst/stack/client"
-
- export const Route = createFileRoute("/pages/$")({
- ssr: true,
- component: Page,
- loader: async ({ params, context }) => {
- const routePath = normalizePath(params._splat)
- const stackClient = getStackClient(context.queryClient)
- const route = stackClient.router.getRoute(routePath)
-
- if (!route) throw notFound()
- if (route?.loader) await route.loader()
-
- return { meta: await route?.meta?.() }
- },
- head: ({ loaderData }) => {
- return loaderData?.meta && Array.isArray(loaderData.meta)
- ? { meta: loaderData.meta }
- : { meta: [{ title: "No Meta" }], title: "No Meta" }
- },
- notFoundComponent: () => This page doesn't exist!
- })
-
- function Page() {
- const context = Route.useRouteContext()
- const { _splat } = Route.useParams()
- const routePath = normalizePath(_splat)
- const route = getStackClient(context.queryClient).router.getRoute(routePath)
-
- return route && route.PageComponent ? : Route not found
- }
- ```
-
-
-
- **Key steps to get right by hand:**
- - **Server-side data loading**: Call `route.loader()` before rendering to prefetch data into React Query cache
- - **Hydration**: Use `dehydrate()` to serialize prefetched data for the client (not required with TanStack Start)
- - **Error handling**: Configure your query client with `shouldDehydrateQuery` to include failed queries in dehydration, preventing client-side refetching on errors
- - **Metadata generation**: Use `route.meta()` with framework-specific meta functions for SEO
- - **404 handling**: Return `notFound()` or your framework's equivalent function when routes don't exist
-
-
+ Use these options to customize the entry factory without reimplementing
+ route matching, loader ordering, hydration, metadata, or 404 handling.
+
@@ -1116,13 +841,13 @@ In order to use BTST, your application must meet the following requirements:
- ✅ Database adapter that connects plugins to your database
- ✅ Client-side router with SSR support
- ✅ React Query integration for data fetching
- - ✅ Framework-specific overrides
+ - ✅ Framework router, API, and optional auth services configured once
**Next steps:**
1. **Add plugins** to both backend and client configurations:
- Backend: `plugins: { blog: blogBackendPlugin() }`
- - Client: `plugins: { blog: blogClientPlugin() }`
+ - Client: `plugins: { blog: blogClientPlugin({ ... }) }`
2. **Visit your pages** at `/pages/*` to see plugin routes in action
diff --git a/docs/content/docs/plugins/better-auth-ui.mdx b/docs/content/docs/plugins/better-auth-ui.mdx
index 1aba119b..7d4d3b27 100644
--- a/docs/content/docs/plugins/better-auth-ui.mdx
+++ b/docs/content/docs/plugins/better-auth-ui.mdx
@@ -96,7 +96,7 @@ export function getStackClient(queryClient: QueryClient) {
// Auth plugin — sign-in, sign-up, forgot-password, magic-link, etc.
auth: authClientPlugin({
siteBaseURL: baseURL,
- siteBasePath: "/p", // prefix used in your catch-all route
+ siteBasePath: "/p",
}),
// Account plugin — settings, security, API keys, teams, organizations
@@ -123,18 +123,17 @@ Configure the plugin overrides in your catch-all layout file. The `auth` overrid
- ```tsx title="app/p/[[...all]]/layout.tsx"
+ ```tsx title="app/p/layout.tsx"
"use client"
- import { StackProvider } from "@btst/stack/context"
+ import { StackProvider, type StackAuthProvider } from "@btst/stack/context"
+ import { nextRouter } from "@btst/stack/next"
import type {
AuthPluginOverrides,
AccountPluginOverrides,
OrganizationPluginOverrides,
} from "@btst/better-auth-ui/client"
import { authClient } from "@/lib/auth-client"
- import Link from "next/link"
- import { useRouter } from "next/navigation"
import type { ReactNode } from "react"
type PluginOverrides = {
@@ -143,21 +142,33 @@ Configure the plugin overrides in your catch-all layout file. The `auth` overrid
organization: OrganizationPluginOverrides
}
- export default function PagesLayout({ children }: { children: ReactNode }) {
- const router = useRouter()
+ const stackAuth = {
+ getIdentity: async () => {
+ const { data } = await authClient.getSession()
+ return data?.user ?? null
+ },
+ loginPath: "/p/auth/sign-in",
+ can: ({ resource, action, identity }) =>
+ Boolean(identity && authorize(identity, resource, action)),
+ } satisfies StackAuthProvider
- // Shared auth configuration — spread into each plugin override
+ export default function PagesLayout({ children }: { children: ReactNode }) {
+ // Better Auth UI-specific configuration shared by its three plugins
const authConfig = {
authClient,
- navigate: router.push,
- replace: router.replace,
- onSessionChange: () => router.refresh(),
- Link,
}
+ const baseURL =
+ typeof window !== "undefined"
+ ? window.location.origin
+ : process.env.BASE_URL || "http://localhost:3000"
+
return (
basePath="/p"
+ router={nextRouter()}
+ api={{ baseURL, basePath: "/api/data" }}
+ auth={stackAuth}
overrides={{
auth: {
...authConfig,
@@ -266,6 +277,11 @@ The exact sub-paths come from the view paths constants in the library and match
### Auth Plugin (`AuthPluginOverrides`)
+`Link`, `navigate`, and `replace` in this table are optional APIs of the
+external `@btst/better-auth-ui` package. They are not the removed built-in BTST
+override fields; the recommended setup above uses `StackProvider.router` and
+the package defaults instead.
+
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `authClient` | `AnyAuthClient` | Required | Better Auth client |
diff --git a/docs/content/docs/plugins/blog.mdx b/docs/content/docs/plugins/blog.mdx
index 89d1e851..969a359c 100644
--- a/docs/content/docs/plugins/blog.mdx
+++ b/docs/content/docs/plugins/blog.mdx
@@ -317,7 +317,24 @@ blogClientPlugin({
### Adding Authorization
-To add authorization rules and customize behavior, you can use the lifecycle hooks defined in the API Reference section below. These hooks allow you to control access to API endpoints, add logging, and customize the plugin's behavior to fit your application's needs.
+Configure `StackProvider.auth` once for client routes and controls. Blog checks
+`blog:draft/read`, `blog:post/create`, `blog:post/update`, and
+`blog:post/delete`. Use client loader hooks for SSR checks and backend
+lifecycle hooks for authoritative API authorization.
+
+```tsx title="app/pages/layout.tsx"
+ session?.user ?? null,
+ loginPath: "/auth/sign-in",
+ can: ({ resource, action, identity }) =>
+ Boolean(identity && canManageBlog(identity, resource, action)),
+ }}
+ // router, api, and Blog-specific overrides
+>
+ {children}
+
+```
## API Reference
diff --git a/docs/content/docs/plugins/comments.mdx b/docs/content/docs/plugins/comments.mdx
index 59a7836a..a43c57ee 100644
--- a/docs/content/docs/plugins/comments.mdx
+++ b/docs/content/docs/plugins/comments.mdx
@@ -140,11 +140,14 @@ export const getStackClient = (queryClient: QueryClient) => {
// },
}),
},
- queryClient,
})
}
```
+The factory fields above configure SSR loaders and metadata. Do not repeat API
+paths, router functions, or identity values in `CommentsPluginOverrides` or
+component props; browser-side Comments UI reads them from `StackProvider`.
+
### 3. Add CSS Import
diff --git a/docs/content/docs/plugins/form-builder.mdx b/docs/content/docs/plugins/form-builder.mdx
index d7ce4ee2..9165bc05 100644
--- a/docs/content/docs/plugins/form-builder.mdx
+++ b/docs/content/docs/plugins/form-builder.mdx
@@ -163,10 +163,9 @@ type PluginOverrides = {
api={{ baseURL, basePath: "/api/data" }}
overrides={{
"form-builder": {
- // Optional file upload for file fields
- uploadFile: async (file) => {
- // Your file upload logic
- return "https://example.com/file.pdf"
+ // Optional custom field implementations
+ fieldComponents: {
+ file: MyFileField,
},
// Lifecycle hooks
onRouteRender: async (routeName, context) => {
@@ -448,10 +447,10 @@ formBuilderBackendPlugin({
// Submissions list authorization
onBeforeListSubmissions: async (formId, ctx) => {
- return isAdmin(ctx.headers)
+ if (!await isAdmin(ctx.headers)) throw new Error("Admin access required")
},
- onBeforeDeleteSubmission: async (submissionId, ctx) => {
- return isAdmin(ctx.headers)
+ onBeforeSubmissionDeleted: async (submissionId, ctx) => {
+ if (!await isAdmin(ctx.headers)) throw new Error("Admin access required")
},
// Error handling
@@ -517,6 +516,25 @@ import { redirect } from "next/navigation"
**Use client hooks for SSR.** These hooks run during server-side data loading and support async operations like session checks. Throw from a `beforeLoad*` hook to deny access; `onLoadError` can then redirect unauthorized users.
+For client navigation and controls, configure `StackProvider.auth` once. Form
+Builder uses `form-builder:form` with `create`, `update`, and `delete`
+actions, and `form-builder:submission` with `read` and `delete` actions.
+Loader and backend hooks remain responsible for server-side enforcement.
+
+```tsx title="app/pages/layout.tsx"
+ session?.user ?? null,
+ loginPath: "/auth/sign-in",
+ can: ({ resource, action, identity }) =>
+ Boolean(identity && canManageForms(identity, resource, action)),
+ }}
+ // router, api, and plugin-specific overrides
+>
+ {children}
+
+```
+
## API Endpoints
The Form Builder plugin exposes these REST endpoints:
@@ -651,18 +669,16 @@ Creates the client plugin with routes and SSR loaders.
| Property | Type | Required | Description |
|----------|------|----------|-------------|
-| `apiBaseURL` | `string` | Yes | Base URL for API requests |
-| `apiBasePath` | `string` | Yes | API path prefix |
-| `navigate` | `(path: string) => void` | Yes | Navigation function |
-| `Link` | `ComponentType` | No | Link component |
-| `refresh` | `() => void` | No | Refresh function |
-| `uploadFile` | `(file: File) => Promise` | No | File upload handler |
| `fieldComponents` | `Record` | No | Custom field components |
| `localization` | `FormBuilderLocalization` | No | Custom labels |
| `showAttribution` | `boolean` | No | Show BTST attribution |
+| `headers` | `HeadersInit` | No | Additional headers for client API calls |
| `onRouteRender` | `(route, context) => void` | No | Lifecycle hook |
| `onRouteError` | `(route, error, context) => void` | No | Error hook |
+Router and API services are not override fields in v3. Configure them with
+`StackProvider.router` and `StackProvider.api`.
+
#### FormBuilderClientHooks
| Hook | Parameters | Return | Description |
diff --git a/docs/content/docs/plugins/kanban.mdx b/docs/content/docs/plugins/kanban.mdx
index 31909e30..22d85b78 100644
--- a/docs/content/docs/plugins/kanban.mdx
+++ b/docs/content/docs/plugins/kanban.mdx
@@ -101,6 +101,12 @@ export const getStackClient = (queryClient: QueryClient) => {
- `siteBasePath`: Path where your pages are mounted (e.g., `/pages`)
- `queryClient`: React Query client instance
+
+These values configure the client plugin's SSR loaders and metadata. Browser
+routing and API access come from the top-level `StackProvider.router` and
+`StackProvider.api` values below.
+
+
### 3. Import Plugin CSS
Add the kanban plugin CSS to your global stylesheet:
@@ -236,17 +242,10 @@ Configure top-level framework wiring and kanban-specific overrides in your `Stac
-**Required overrides:**
-- `apiBaseURL`: Base URL for API calls
-- `apiBasePath`: Path where your API is mounted
-- `navigate`: Function for programmatic navigation
-- `resolveUser`: Function to resolve user info from ID (for assignee display)
-- `searchUsers`: Function to search/list users (for assignee picker)
-
-**Optional overrides:**
-- `Link`: Custom Link component (defaults to `` tag)
-- `refresh`: Function to refresh server-side cache
-- `localization`: Custom localization strings
+The only required Kanban overrides are `resolveUser` and `searchUsers` for
+assignee display and selection. Optional overrides include `uploadImage`,
+`imagePicker`, localization, attribution, headers, route lifecycle hooks, and
+`taskDetailBottomSlot`. Router and API fields are not plugin overrides in v3.
### 5. Generate Database Schema
diff --git a/docs/content/docs/plugins/media.mdx b/docs/content/docs/plugins/media.mdx
index 732ea198..aa7d5acf 100644
--- a/docs/content/docs/plugins/media.mdx
+++ b/docs/content/docs/plugins/media.mdx
@@ -103,7 +103,10 @@ export const getStackClient = (queryClient: QueryClient) => {
- `queryClient`: React Query client used for prefetching and caching
-The media client plugin registers the `/media` page route and prefetches the initial asset grid and folder tree during SSR. It expects the same API base settings you use elsewhere in your BTST client setup.
+The media client plugin registers the `/media` page route and prefetches the
+initial asset grid and folder tree during SSR. Browser-side API calls and
+navigation use the top-level `StackProvider.api` and
+`StackProvider.router` values below.
### 3. Import Plugin CSS
@@ -116,7 +119,7 @@ Add the media plugin CSS to your global stylesheet:
This includes the built-in media library UI, picker layout, folder tree, upload states, and image previews.
-### 4. Add Context Overrides
+### 4. Configure the Provider
Configure top-level framework wiring and media-specific overrides in your `StackProvider`:
@@ -125,8 +128,8 @@ Configure top-level framework wiring and media-specific overrides in your `Stack
```tsx title="app/pages/layout.tsx"
import { StackProvider } from "@btst/stack/context"
import { nextRouter } from "@btst/stack/next"
- import { uploadAsset, type MediaPluginOverrides } from "@btst/stack/plugins/media/client"
- import { QueryClient } from "@tanstack/react-query"
+ import type { MediaPluginOverrides } from "@btst/stack/plugins/media/client"
+ import { useQueryClient } from "@tanstack/react-query"
const getBaseURL = () =>
typeof window !== "undefined"
@@ -138,7 +141,7 @@ Configure top-level framework wiring and media-specific overrides in your `Stack
}
export default function Layout({ children }) {
- const queryClient = new QueryClient()
+ const queryClient = useQueryClient()
const baseURL = getBaseURL()
return (
@@ -166,7 +169,7 @@ Configure top-level framework wiring and media-specific overrides in your `Stack
import { StackProvider } from "@btst/stack/context"
import { reactRouter } from "@btst/stack/react-router"
import type { MediaPluginOverrides } from "@btst/stack/plugins/media/client"
- import { QueryClient } from "@tanstack/react-query"
+ import { useQueryClient } from "@tanstack/react-query"
const getBaseURL = () =>
typeof window !== "undefined"
@@ -178,7 +181,7 @@ Configure top-level framework wiring and media-specific overrides in your `Stack
}
export default function Layout() {
- const queryClient = new QueryClient()
+ const queryClient = useQueryClient()
const baseURL = getBaseURL()
return (
@@ -206,7 +209,7 @@ Configure top-level framework wiring and media-specific overrides in your `Stack
import { tanstackRouter } from "@btst/stack/tanstack"
import type { MediaPluginOverrides } from "@btst/stack/plugins/media/client"
import { Outlet } from "@tanstack/react-router"
- import { QueryClient } from "@tanstack/react-query"
+ import { useQueryClient } from "@tanstack/react-query"
const getBaseURL = () =>
typeof window !== "undefined"
@@ -218,7 +221,7 @@ Configure top-level framework wiring and media-specific overrides in your `Stack
}
function Layout() {
- const queryClient = new QueryClient()
+ const queryClient = useQueryClient()
const baseURL = getBaseURL()
return (
@@ -413,6 +416,20 @@ When `StackProvider` has an auth provider with `can()`, the Media UI checks thes
Without an auth provider, all controls remain available for backward compatibility. Client checks only control presentation; continue using backend hooks such as `onBeforeUpload`, `onBeforeDelete`, and `onBeforeListAssets` as the security boundary.
+```tsx title="app/pages/layout.tsx"
+ session?.user ?? null,
+ loginPath: "/login",
+ can: ({ resource, action, identity }) =>
+ Boolean(identity && canUseMedia(identity, resource, action)),
+ }}
+ // router, api, and media-specific overrides
+>
+ {children}
+
+```
+
### Translation and notifications
Built-in Media UI strings go through the `StackProvider` i18n provider under `media.*` keys. Action feedback goes through the `notify` provider instead of importing a toast library directly. With neither provider configured, the existing English copy and default notifications are used.
@@ -804,4 +821,6 @@ mediaClientPlugin({
})
```
-The ejected library page still relies on your `media` `StackProvider` overrides for API configuration, navigation, upload mode, and hooks.
+The ejected library page reads API and navigation services from the top-level
+`StackProvider` and media-specific upload mode, headers, and hooks from the
+`media` override.
diff --git a/docs/content/docs/plugins/route-docs.mdx b/docs/content/docs/plugins/route-docs.mdx
index c083dd69..d2ee1df0 100644
--- a/docs/content/docs/plugins/route-docs.mdx
+++ b/docs/content/docs/plugins/route-docs.mdx
@@ -37,33 +37,32 @@ Ensure you followed the general [framework installation guide](/installation) fi
Import and register the Route Docs client plugin in your client configuration:
```tsx title="lib/stack-client.tsx"
-import { stackClient } from "@btst/stack/client"
-import { blogClientPlugin } from "@btst/stack/plugins/blog/client"
+import { createStackClient } from "@btst/stack/client"
import { routeDocsClientPlugin } from "@btst/stack/plugins/route-docs/client"
-
-const { ClientProvider, routes, ...client } = stackClient({
- basePath: "/pages",
- apiBasePath: "/api/data",
- queryClient: queryClient,
- plugins: {
- blog: blogClientPlugin({ queryClient }),
- // Add Route Docs plugin - it will document all other client plugins
- routeDocs: routeDocsClientPlugin({
- queryClient: queryClient,
- siteBasePath: "/pages",
- title: "Client Route Documentation",
- description: "Documentation for all client routes in this application",
- }),
- },
-})
-
-export { ClientProvider, routes, client }
+import type { QueryClient } from "@tanstack/react-query"
+
+export const getStackClient = (queryClient: QueryClient) =>
+ createStackClient({
+ plugins: {
+ // Keep your other client plugin entries here. Route Docs introspects them.
+ routeDocs: routeDocsClientPlugin({
+ queryClient,
+ siteBasePath: "/pages",
+ title: "Client Route Documentation",
+ description: "Documentation for all client routes in this application",
+ }),
+ },
+ })
```
The Route Docs plugin is client-only. There is no backend plugin required.
+Render it under the standard provider-only layout from the
+[installation guide](/installation): one top-level router and API config, with
+no Route Docs override block.
+
## Accessing the Documentation
Once configured, navigate to your route docs page:
@@ -140,11 +139,21 @@ The Route Docs page exposes your application's route structure. Consider these m
3. **Sensitive Routes** - Be aware that all registered routes will be documented
```tsx
-// Example: Only enable in development
-const plugins = {
- blog: blogClientPlugin({ queryClient }),
- ...(process.env.NODE_ENV === "development" && {
- routeDocs: routeDocsClientPlugin({ queryClient }),
- }),
-}
+// Example: only register Route Docs in development
+const routeDocs =
+ process.env.NODE_ENV === "development"
+ ? {
+ routeDocs: routeDocsClientPlugin({
+ queryClient,
+ siteBasePath: "/pages",
+ }),
+ }
+ : {}
+
+createStackClient({
+ plugins: {
+ // ...your other client plugins,
+ ...routeDocs,
+ },
+})
```
diff --git a/docs/content/docs/plugins/ui-builder.mdx b/docs/content/docs/plugins/ui-builder.mdx
index 90bb97e6..855c4069 100644
--- a/docs/content/docs/plugins/ui-builder.mdx
+++ b/docs/content/docs/plugins/ui-builder.mdx
@@ -783,6 +783,25 @@ import { redirect } from "next/navigation"
})
```
+Configure `StackProvider.auth` for client navigation and controls. UI Builder
+uses `ui-builder:page` with `read`, `create`, `update`, and `delete`
+actions. Keep SSR loader checks above and backend hooks as the server-side
+authorization boundary.
+
+```tsx title="app/pages/layout.tsx"
+ session?.user ?? null,
+ loginPath: "/auth/sign-in",
+ can: ({ resource, action, identity }) =>
+ Boolean(identity && canManagePages(identity, resource, action)),
+ }}
+ // router, api, and plugin-specific overrides
+>
+ {children}
+
+```
+
### UIBuilderClientHooks
| Hook | Parameters | Return | Description |
@@ -898,15 +917,18 @@ Creates the client plugin with routes and SSR loaders.
| Property | Type | Required | Description |
|----------|------|----------|-------------|
-| `apiBaseURL` | `string` | Yes | Base URL for API requests |
-| `apiBasePath` | `string` | Yes | API path prefix |
-| `navigate` | `(path: string) => void` | Yes | Navigation function |
-| `Link` | `ComponentType` | No | Link component |
-| `refresh` | `() => void` | No | Refresh function |
+| `headers` | `HeadersInit` | No | Additional headers for client API calls |
| `componentRegistry` | `ComponentRegistry` | No | Custom component registry |
| `functionRegistry` | `FunctionRegistry` | No | Functions available to UI Builder event bindings |
| `localization` | `UIBuilderLocalizationOverrides` | No | Nested overrides for built-in page, editor, renderer, and notification copy |
| `showAttribution` | `boolean` | No | Show BTST attribution |
+| `siteBasePath` | `string` | No | UI Builder admin-page base path |
+| `hooks` | `UIBuilderClientHooks` | No | SSR loader hooks |
+| `onRouteRender` | `(route, context) => void` | No | Route analytics hook |
+| `onRouteError` | `(route, error, context) => void` | No | Route error hook |
+
+Router and API services are not override fields in v3. Configure them with
+`StackProvider.router` and `StackProvider.api`.
#### defaultComponentRegistry
@@ -1165,6 +1187,8 @@ import { PageBuilderPage } from "@/components/btst/ui-builder/client/components/
uiBuilderClientPlugin({
apiBaseURL: "...",
apiBasePath: "/api/data",
+ siteBaseURL: "...",
+ siteBasePath: "/pages",
queryClient,
pageComponents: {
pageList: PageListPage, // replaces the page list page
diff --git a/docs/content/docs/shadcn-registry.mdx b/docs/content/docs/shadcn-registry.mdx
index 1158e93f..0993a71f 100644
--- a/docs/content/docs/shadcn-registry.mdx
+++ b/docs/content/docs/shadcn-registry.mdx
@@ -283,36 +283,38 @@ mediaClientPlugin({
})
```
-Keep your `media` overrides configured in `StackProvider` so the ejected component can resolve API config, upload mode, and hooks correctly.
+Keep the top-level `StackProvider.api` / `router` services and your
+media-specific upload mode and hooks configured so the ejected component uses
+the same runtime services as the built-in page.
## Available `pageComponents` keys
The table below covers the plugins that currently support `pageComponents` overrides directly. Comments still use the direct-import pattern shown above.
-| Plugin | Key | Props | Description |
+| Plugin | Key | Route context passed to override | Description |
|---|---|---|---|
| Blog | `posts` | — | Published posts list |
| Blog | `drafts` | — | Drafts list |
| Blog | `newPost` | — | New post editor |
-| Blog | `post` | `{ slug: string }` | Single post detail |
-| Blog | `editPost` | `{ slug: string }` | Edit post editor |
-| Blog | `tag` | `{ tagSlug: string }` | Tag + tagged posts |
+| Blog | `post` | `{ params: { slug: string } }` | Single post detail |
+| Blog | `editPost` | `{ params: { slug: string } }` | Edit post editor |
+| Blog | `tag` | `{ params: { tagSlug: string } }` | Tag + tagged posts |
| AI Chat | `chat` | — | Chat home page |
-| AI Chat | `chatConversation` | `{ conversationId: string }` | Conversation detail |
+| AI Chat | `chatConversation` | `{ params: { id: string } }` | Conversation detail |
| CMS | `dashboard` | — | CMS dashboard |
-| CMS | `contentList` | `{ typeSlug: string }` | Content list per type |
-| CMS | `newContent` | `{ typeSlug: string }` | New content editor |
-| CMS | `editContent` | `{ typeSlug: string; id: string }` | Edit content editor |
+| CMS | `contentList` | `{ params: { typeSlug: string } }` | Content list per type |
+| CMS | `newContent` | `{ params: { typeSlug: string } }` | New content editor |
+| CMS | `editContent` | `{ params: { typeSlug: string; id: string } }` | Edit content editor |
| Form Builder | `formList` | — | Form list |
| Form Builder | `newForm` | — | New form editor |
-| Form Builder | `editForm` | `{ id: string }` | Form editor |
-| Form Builder | `submissions` | `{ formId: string }` | Form submissions |
+| Form Builder | `editForm` | `{ params: { id: string } }` | Form editor |
+| Form Builder | `submissions` | `{ params: { id: string } }` | Form submissions |
| UI Builder | `pageList` | — | Page list |
| UI Builder | `newPage` | — | New page builder |
-| UI Builder | `editPage` | `{ id: string }` | Page builder editor |
+| UI Builder | `editPage` | `{ params: { id: string } }` | Page builder editor |
| Kanban | `boards` | — | Boards list |
| Kanban | `newBoard` | — | New board |
-| Kanban | `board` | `{ boardId: string }` | Board detail |
+| Kanban | `board` | `{ params: { boardId: string } }` | Board detail |
| Media | `library` | — | Media library page |
## What the registry installs