BTST

Breaking Changes

Migration guides for upgrading between major versions

This page documents breaking changes between major versions and provides migration guides.


v3 → v4: Better Auth 1.7 and Better Auth UI 1.7

BTST v4 uses Better DB 3, rebased onto Better Auth 1.7.6. The optional @btst/better-auth-ui 3 plugin packages upstream Better Auth UI 1.7.26 React/shadcn components and connects them to BTST routing and shared services. Existing BTST backend plugins and framework entry factories retain their APIs.

Upgrade the packages together:

PackageVersion
@btst/stack, @btst/codegen4.5.1
@btst/db, selected @btst/adapter-*, @btst/cli3.0.0
Optional @btst/better-auth-ui3.0.1
better-auth, @better-auth/core1.7.6
@better-auth/utils0.4.2
@better-fetch/fetch1.3.2
better-call1.4.0
React and React DOM>=19.2.6
Tailwind CSS and its PostCSS/Vite integration>=4.3.2
@tanstack/react-query, @tanstack/query-core5.102.0

For example, a Drizzle application can install the core cohort with:

pnpm add --save-exact @btst/stack@4.5.1 @btst/adapter-drizzle@3.0.0 \
  better-auth@1.7.6 @better-auth/core@1.7.6 \
  @better-auth/utils@0.4.2 @better-fetch/fetch@1.3.2 better-call@1.4.0
pnpm add --save-dev --save-exact @btst/codegen@4.5.1

Use Drizzle ORM ^0.45.2 or a compatible 1.0 release candidate supported by the adapter. Better Auth moved native joins from experimental.joins to advanced.database.joins; the BTST adapters enable native joins automatically. If the application configures this directly on its Better Auth instance, migrate that setting too.

Keep React Query and Query Core on the same tested version. The generated TanStack Start integration uses 5.102.0; Query Core 5.104.0 changes hydration behavior at the end of its SSR query stream.

The Better Auth UI companion now uses upstream feature plugins. Follow the Better Auth UI companion guide to migrate provider configuration and select optional features. Applications continue to own their Better Auth server, credentials, schema migrations, and authorization policy.

Run the application's typecheck, production build, sign-in/account flows, and any enabled organization/admin/billing flows before deployment. Keep the previous application artifact and lockfile available while verifying production. The older version tables below describe their historical migrations.


v2 or release candidate → stable v3: production playbook

Use this section as the canonical migration order for a production application. The RC2→RC3 and v2 framework sections later on this page remain detailed references for individual mechanical changes; they are not separate upgrade paths.

Do not upgrade one package at a time in a deployed application. Pin the exact verified cohort, migrate on a branch with a database backup, and keep the old application artifact and lockfile available until production verification is complete.

0. Pin the verified cohort and a rollback point

Use this exact stable v3 cohort:

RoleExact version
Core@btst/stack@3.0.0
Scaffold and command wrapper@btst/codegen@0.2.0
Optional Better Auth UI companion@btst/better-auth-ui@2.0.0
Better DB and the selected BTST adapter2.2.3
Database CLI for generation/migration@btst/cli@2.2.4, delegated by Codegen 0.2.0 from the consumer project directory
Better Auth and Corebetter-auth@1.6.16, @better-auth/core@1.6.16
Better Auth utilities and transport@better-auth/utils@0.4.1, @better-fetch/fetch@1.2.2, better-call@1.3.6
API-key and passkey declarations, when the companion is installed@better-auth/api-key@1.6.16, @better-auth/passkey@1.6.16

Retain the Better Auth 1.6.16 and Better DB/adapter 2.2.3 cohorts unless a later migration guide explicitly changes them. Do not move this release to Better Auth 1.7.x, and do not use a floating latest, next, caret, or workspace range in a production migration.

For example, a Drizzle application without the optional auth companion can pin the stable cohort with:

pnpm add --save-exact @btst/stack@3.0.0 @btst/adapter-drizzle@2.2.3
pnpm add --save-dev --save-exact @btst/codegen@0.2.0
pnpm install --frozen-lockfile

If the application selects Better Auth UI, add the complete aligned auth cohort shown in Better Auth UI companion rather than asking the package manager to repair peers opportunistically.

Before changing code or data:

  • record the currently deployed commit, package-manager version, runtime version, build command, start command, and environment names;
  • commit the manifest and lockfile, export the generated schema, and take a restorable database backup or provider snapshot;
  • inventory every BTST plugin, embedded component, direct hook, provider root, custom route, lifecycle hook, auth rule, and trusted/background call site;
  • capture a production-like smoke baseline, including representative existing records and anonymous, regular-user, and privileged-user behavior; and
  • create a migration branch and keep the previous application artifact deployable. Never treat a down migration as the only rollback for data that the new application has already written.

1. Apply the ownership changes in this order

ConcernRemoved or intermediate shapeStable-v3 shape
Backend constructorstack(...)createBackendStack(...) from @btst/stack/api
Client constructorcreateStackClient(...), stackClient(...)createClientStack(...) from @btst/stack/client
Client runtimeAPI, site, query client, and headers repeated in plugins/providerone resolved createClientStack({ api, site, queryClient, plugins })
Plugin IDskebab-case programmatic keys such as ai-chat and form-buildercanonical camelCase keys such as aiChat and formBuilder; package paths and URL slugs stay kebab-case
Backend factoriespositional arguments or top-level hook callbackszero or one options object with callbacks under hooks and required domain dependencies explicit
Lifecyclemixed read/create/error spellings and boolean hook denialsonBefore<Action><Entity> / onAfter<Action><Entity> / onError<Action><Entity> and thrown domain failures
ProviderAPI/base-path fields and a manual override genericbrowser-safe stack, framework router, optional auth, initialIdentity, and genuine application services
Overridesempty blocks used to activate plugins or duplicated runtime/auth fieldsoptional inferred keys containing only plugin-specific browser or presentation customization
Request callsambiguous api namespaceforRequest(request).operations
Trusted callsinternal or a boolean bypasstrusted, which skips user authorization but retains validation, trusted facts, domain behavior, transactions, and lifecycle
Low-level callsordinary app code reaching exported getters/mutationsnarrow raw prefetch escape hatches; standalone primitives remain caller-composed and are not the ordinary app API

The canonical browser composition is:

lib/stack-client.tsx
const clientStack = createClientStack({
  api: { baseURL, basePath: "/api/data" },
  site: { baseURL, basePath: "/pages" },
  queryClient,
  plugins: {
    blog: blogClientPlugin(),
    comments: commentsClientPlugin(),
  },
})

<StackProvider
  stack={clientStack}
  router={router}
  auth={clientAuth}
  initialIdentity={initialIdentity}
  overrides={{ blog: { uploadImage } }}
>
  {children}
</StackProvider>

Omit overrides when there is no customization. An empty override does not register or activate a plugin. The registered resolved definitions infer the allowed keys and exact value types; do not restore a provider generic or a manual application override map.

Build one request-specific client stack for server loaders and metadata, with filtered headers under api.headers, and a separate stable browser stack without request headers. Only schema-validated identity and trusted API/site origins may cross the server/client boundary. Never serialize a backend stack, cookies, authorization headers, proxy headers, secrets, or a request-specific client stack.

Keep resolved client plugin definitions server-import-safe. Put React state, browser auth clients, upload callbacks, navigation, and the provider itself in a client-only module. A path-only per-plugin endpoint replacement inherits the top-level origin and filtered request headers; a replacement origin is a new transport boundary and must provide its own path and deliberately selected credentials. Preserve that boundary instead of forwarding server credentials to another origin.

2. Register plugins, factories, and lifecycle hooks

Register both halves of a full-stack plugin under the same canonical key. OpenAPI is backend-only; Route Docs is client-only; UI Builder is client-only and composes the registered CMS contract. Do not invent a matching half for a one-sided plugin.

Move each backend plugin to one options object and each callback to hooks:

createBackendStack({
  basePath: "/api/data",
  adapter,
  auth: serverAuth,
  plugins: {
    blog: blogBackendPlugin({ hooks: { onAfterCreatePost } }),
    comments: commentsBackendPlugin({
      allowEditing: false,
      resolveUser,
      hooks: { onBeforeCreateComment },
    }),
  },
})

The stable lifecycle grammar is onBefore<Action><Entity>, onAfter<Action><Entity>, and onError<Action><Entity>. The complete rename inventory is in Rename every backend lifecycle callback. Update every used plugin, including callbacks referenced indirectly from shared hook objects. A hook runs only after validation, authoritative fact derivation, identity resolution, and authorization have succeeded; it is not a replacement for permission enforcement. Hook denials throw—returning false is no longer a denial.

3. Configure atomic writes explicitly

AI Chat, Form Builder, Kanban, and Media contain operations whose authorization facts and writes must share one isolated transaction. Configure a supported Prisma, Drizzle, or Kysely adapter with transaction: true; do not rely on the sequential fallback. Form Builder does not support generated memory or MongoDB configuration, and Media does not support generated MongoDB configuration, because those combinations cannot provide the required isolation.

See Database adapters for copyable adapter examples and the fail-closed behavior.

4. Migrate authorization as one application-owned rule

Plugins publish schema-backed descriptors and the minimum facts required for an operation. The application owns its identity schema, local rules, and both identity resolvers. Authentication discovers identity; authorization decides whether that identity may perform a typed operation.

Keep the rule module browser-safe:

lib/authorization.ts
import { defineAuthorization } from "@btst/stack/authorization"
import { blogPermissions } from "@btst/stack/plugins/blog/permissions"
import { z } from "zod"

export const authorization = defineAuthorization({
  identity: z.object({ id: z.string(), role: z.enum(["user", "admin"]) }),
  permissions: [blogPermissions] as const,
  rules: ({ blog }) => [
    blog.post.delete.when(({ identity, facts }) =>
      identity !== null &&
      (identity.role === "admin" || identity.id === facts.authorId),
    ),
  ],
})

Bind it separately on each side. These modules are client-only and server-only, respectively:

lib/authorization.client.ts
"use client"

export const clientAuth = createClientAuth({
  authorization,
  getIdentity: () => session?.user ?? null,
  loginPath: "/sign-in",
})

const { CanAccess } = clientAuth
const control = (
  <CanAccess permission={blogPermissions.post.delete({
    id: post.id,
    authorId: post.authorId,
  })}>
    <DeletePostButton />
  </CanAccess>
)
lib/authorization.server.ts
import "server-only"

export const serverAuth = createServerAuth({
  authorization,
  getIdentityFromHeaders: async ({ headers }) => {
    const session = await auth.api.getSession({ headers })
    return session?.user ?? null
  },
})

The client check is a synchronous presentation decision. It makes no permission request and creates no shared authorization-result cache. The backend validates input, derives trusted facts from server data, resolves the request identity, evaluates the descriptor, and only then enters domain and lifecycle execution. A representative plugin operation binds that ordering once:

plugins/posts/api.ts
const deletePost = defineOperation({
  input: z.object({ id: z.string() }),
  permission: blogPermissions.post.delete,
  facts: async ({ input }) => {
    const post = await adapter.findOne({
      model: "post",
      where: [{ field: "id", value: input.id }],
    })
    return { id: input.id, ...(post?.authorId ? { authorId: post.authorId } : {}) }
  },
  execute: async ({ input }) => {
    await adapter.delete({
      model: "post",
      where: [{ field: "id", value: input.id }],
    })
    return { success: true } as const
  },
})

Do not accept authorId, role, tenant ownership, record visibility, or other authoritative facts from the browser merely because the same shapes are used for a local UI preview. Row and tenant query scoping is a separate server-only data concern, not a boolean authorization check.

Once server authorization is enabled, a missing rule denies. Ordinary anonymous and authenticated denials become 401 and 403, respectively. Invalid identity, schema, transport, fact derivation, and policy execution remain observable errors; never convert them to false. Omitting server authorization preserves the documented permissive compatibility behavior while the migration is staged, but it should be an explicit temporary choice.

Audit every call site against its trust contract:

await backend.forRequest(request).operations.blog.deletePost({ id })
await backend.trusted.blog.deletePost({ id })
await backend.raw.blog.prefetchForRoute("post", queryClient, { slug })

The first path is request-authorized. The second is for a trusted job or server workflow and skips only user authorization. The third is a narrow composition escape hatch, not an alternate business API.

For a managed or separately deployed backend, publish only the rule-free, versioned contract and descriptors:

packages/backend-contract/authorization.ts
export const authorizationContract = defineAuthorizationContract({
  identity: z.object({ id: z.string(), role: z.enum(["user", "admin"]) }),
  permissions: [blogPermissions] as const,
})

The browser may bind that contract to createRemoteAuthorizationEvaluator, but the remote service must parse the contract version and facts, resolve its own identity, re-read authoritative records, and evaluate server-owned rules. It never trusts browser identity or ownership facts. See Authorization for the transport example. Core intentionally exports no provider-specific auth adapter and no global open-string useCan or CanAccess API.

5. Adopt framework entries and tri-state hydration

Use the framework entry factories instead of copied route resolution, loader, metadata, dehydration, or 404 logic:

FrameworkAPI routePage routeProvider routerIdentity layout
Next.jstoNextRouteHandlerscreateNextPagenextRouter()createNextLayout from @btst/stack/next/server
React RoutertoReactRouterHandlerscreateReactRouterPagereactRouter()createReactRouterLayout on the parent route
TanStack StarttoTanStackHandlerscreateTanStackPageOptionstanstackRouter()createTanStackLayout plus a server function

Hydrate identity at the layout or parent-route boundary that owns the complete provider subtree:

<StackProvider
  stack={browserStack}
  router={frameworkRouter}
  auth={clientAuth}
  initialIdentity={initialIdentity}
>
  {children}
</StackProvider>

initialIdentity has three deliberate states:

ValueMeaningInitial client behavior
undefined or omittedno server snapshotresolve identity in the browser
nullsettled anonymous snapshotdo not duplicate the initial request
validated identitysettled authenticated snapshotuse it without a duplicate initial request

Next.js request-aware pages and layouts must construct their server client from the current request and keep static/ISR routes in a separate header-free route group. React Router resolves the snapshot in the parent layout loader so it covers the full <Outlet />. TanStack Start resolves it in a server function used by the parent route loader and later client navigations. The complete, copyable implementations are in Authorization: hydrate identity at the layout boundary.

When an application-owned route renders one plugin page directly instead of using the catch-all, keep a dedicated wrapper and pass the page component its declarative { params } route context. Supply synthetic params only when that wrapper intentionally fixes a resource; do not call route internals or restore the removed named-prop adapters. The exact parameter mappings are listed in Update parameterized page-component overrides.

After login, logout, or account switching, refresh at the application/framework seam: Next.js router.refresh(), React Router revalidator.revalidate(), TanStack router.invalidate(), or clientAuth.useIdentity().refetch().

6. Migrate embedded surfaces and every provider root

The catch-all pages layout is not automatically an ancestor of UI embedded in the rest of the application. Inventory and migrate CommentThread, CommentCount, FormRenderer, direct plugin hooks, and cards such as PostCard or TaskCard. Each rendered surface must be below a StackProvider whose resolved stack registers that plugin and below the same QueryClient provider used to create the stack.

If a modal, parallel route, portal host, microfrontend, or independently mounted widget has a separate React root, give that root its own stable browser stack and provider using the same trusted origin snapshot and auth contract. Context does not cross sibling roots. Hydrate identity per root or intentionally leave it undefined; never copy a server stack or request headers into the new root.

Remove apiBaseURL, apiBasePath, headers, and current-user props from embedded components. They read transport and identity from the nearest provider. Keep CommentThread.loginHref only when the resource needs a specific sign-in return URL; it overrides the provider's general loginPath. Test embedded mutations and counts as well as their first render—a static card that looks correct can still be bound to the wrong endpoint or identity cache.

7. Regenerate or merge the framework scaffold

@btst/codegen owns application scaffolding. The focused @btst/codegen@0.2.0 delegates generate and migrate to @btst/cli@2.2.4 from the consumer project directory. That behavior loads the application's TypeScript/JavaScript aliases and standard Next.js environment files, resolves its Prisma or Drizzle adapter and ORM peers, and ignores only a bare import "server-only" marker while evaluating the server config.

Use the scaffold as a reference diff for an existing application rather than blindly overwriting owned files:

npx @btst/codegen@0.2.0 init --framework=nextjs --adapter=drizzle \
  --plugins=blog,comments --cwd=. --skip-install
npx @btst/codegen@0.2.0 generate \
  --orm=drizzle --config=lib/stack.ts --output=src/db/schema.ts

Select react-router or tanstack for those frameworks. Review every planned write and TODO, preserve application-owned auth/domain dependencies, then run the generated framework's typecheck and production build. See CLI for the supported flags and direct failure fallback.

8. Migrate the Better Auth UI companion

Better Auth remains application-configured and is a prerequisite. The optional companion reads its own Better Auth session and uses native Better Auth account, organization, and permission APIs; it exports no Better Auth-to-BTST client or server auth factory. Map the session into createClientAuth and createServerAuth yourself when business plugins should authorize the same person. Role and tenant fields remain application-owned.

Auth plus account is the minimal runtime integration. Pin the stable cohort exactly when it is selected:

pnpm add --save-exact @btst/better-auth-ui@2.0.0 \
  better-auth@1.6.16 @better-auth/core@1.6.16 \
  @better-auth/api-key@1.6.16 @better-auth/passkey@1.6.16 \
  @better-auth/utils@0.4.1 @better-fetch/fetch@1.2.2 better-call@1.3.6

@btst/codegen@0.2.0 includes the explicit better-auth-ui scaffold selection and generates the minimal auth-and-account path described in the focused guide. It does not generate or replace the application's Better Auth backend, database, schema, providers, secrets, or optional runtime plugins.

API-key and passkey are required declaration peers in the stable package because its complete AuthClient type exposes those surfaces. Installing them satisfies the strict type/dependency graph; it does not enable either feature. Enable organization, API-key, passkey, or multi-session only when the matching Better Auth server and browser plugins are configured. Optional tanstack, instantdb, and triplit companion subpaths have additional peers; the base auth/account integration does not require those adapter peers.

Configure authClient once under the auth override and avatar behavior only under account. Account and organization overrides intentionally reject auth-only fields. Companion route bases derive from the resolved createClientStack({ site }) runtime; do not repeat them in overrides. Use the explicit framework session refresh described above. The focused setup and peer table live in Better Auth UI Companion.

9. Verify data, production behavior, and cleanup

Generate the schema from the migrated stack, diff it against the recorded baseline, and review the ORM migration before applying it. Test against a restored production-like snapshot first. Prefer additive migrations during the deployment window, deploy schema changes before code that needs them, and do not remove old columns or compatibility reads until the rollback window closes. Verify existing rows, relationships, cascades, tenant boundaries, and plugin records—not only newly created fixtures.

Run this checklist against the exact packed/published artifacts and the optimized production server:

  • Clean install succeeds from the exact selected cohort with no undocumented peer repair, duplicate Better Auth type universe, or application dependency on @btst/cli.
  • Typecheck, lint, unit/integration tests, schema generation, reviewed migration, optimized build, and production start pass.
  • Server and client bundles remain separated; no backend stack, request headers, cookies, secrets, or server auth are serialized.
  • Direct navigation, hard refresh, back/forward, 404 and error boundaries, console/server errors, and hydration warnings are clean.
  • SSR, authenticated SSR, SSG/ISR, metadata, sitemap, and browser refetch use the same resolved endpoints.
  • Anonymous, regular-user, and privileged-user controls match authoritative backend results.
  • An allowed operation succeeds; anonymous denial is 401; authenticated denial is 403; a missing rule denies; identity/fact/policy failures remain errors; spoofed browser facts do not grant access; trusted internal execution retains validation, domain behavior, transactions, and lifecycle.
  • Login, logout, account switching, explicit session refresh, and all three initialIdentity states behave without duplicate initial requests.
  • Embedded components outside the catch-all layout, every independent provider root, resource-specific sign-in return URLs, counts/cards/direct hooks, and representative plugin mutations work.
  • Better Auth account, profile, avatar, and only the optional features the application configured work on the retained 1.6.16 cohort.
  • Existing database records remain readable and writable, failed atomic operations roll back, and database plus remote test assets are removed.

Release maintainers prove the clean-room and snippet contracts from the repository root before publishing:

pnpm test:packed-consumers
BTST_ARTIFACT_DIR="$(mktemp -d)"
npm pack @btst/better-auth-ui@2.0.0 --pack-destination "$BTST_ARTIFACT_DIR"
BTST_AUTH_UI_TARBALL="$BTST_ARTIFACT_DIR/btst-better-auth-ui-2.0.0.tgz"
pnpm smoke:packed-consumer -- --fixture core --package-manager npm
pnpm smoke:packed-consumer -- --fixture core --package-manager pnpm
pnpm smoke:packed-consumer -- --fixture auth --package-manager npm \
  --better-auth-ui "$BTST_AUTH_UI_TARBALL"
pnpm smoke:packed-consumer -- --fixture auth --package-manager pnpm \
  --better-auth-ui "$BTST_AUTH_UI_TARBALL"
pnpm --filter @btst/codegen test:better-auth-ui-fixtures
pnpm typecheck

The first command verifies the harness itself; the four smoke commands then install only packed tarballs with npm and pnpm, under strict peers, and exercise core plus the auth cohort. The Better Auth UI gate generates untouched Next.js, React Router, and TanStack Start applications and builds and typechecks each one. The root typecheck includes the constructor, authorization, managed-contract, and hydration consumer fixtures used by this guide. A failure in any gate blocks publication and must be corrected in the guide or implementation rather than repaired by an undocumented fixture edit.

Finally remove migration-only compatibility code: old constructors, positional factory arguments, top-level or retired lifecycle names, kebab-case programmatic IDs, duplicated API/site/query/header wiring, manual override maps, empty activation blocks, render guards, open-string authorization calls, provider-specific core auth bridges, ambiguous api/internal calls, and temporary dual-read or dual-write paths after the rollback window. Commit the final lockfile and deployment evidence with the migration.


Migration reference: v3 RC2 → RC3 canonical stack and plugin DX

RC3 removes the remaining duplicate runtime configuration and historical naming seams. The migration is mechanical: rename the constructors, move shared client runtime to one client stack, nest backend hooks, update programmatic IDs and lifecycle names, and select the server surface whose trust contract matches the caller.

1. Rename both stack constructors

Before:

import { stack } from "@btst/stack"
import { createStackClient } from "@btst/stack/client"

const backend = stack({ /* ... */ })
const client = createStackClient({ /* ... */ })

After:

import { createBackendStack } from "@btst/stack/api"
import { createClientStack } from "@btst/stack/client"

const backend = createBackendStack({ /* ... */ })
const client = createClientStack({ /* ... */ })

Use only createBackendStack and createClientStack in maintained code. The earlier stack and createStackClient names are migration inputs, not parallel constructor stories.

2. Move shared client runtime to one resolved stack

Before, every client plugin and the provider repeated transport, site, cache, and request values:

const blog = blogClientPlugin({
  apiBaseURL: baseURL,
  apiBasePath: "/api/data",
  siteBaseURL: baseURL,
  siteBasePath: "/pages",
  queryClient,
  headers: requestHeaders,
})

<StackProvider
  api={{ baseURL, basePath: "/api/data" }}
  basePath="/pages"
  router={router}
>
  {children}
</StackProvider>

After, create one request-specific stack for loaders and metadata by supplying api.headers, and a separate stable browser stack without request headers:

const clientStack = createClientStack({
  api: { baseURL, basePath: "/api/data" },
  site: { baseURL, basePath: "/pages" },
  queryClient,
  plugins: {
    blog: blogClientPlugin(),
  },
})

<StackProvider
  stack={clientStack}
  router={router}
  auth={clientAuth}
  initialIdentity={initialIdentity}
  overrides={{ blog: { uploadImage } }}
>
  {children}
</StackProvider>

createClientStack is the only owner of API location, site location, QueryClient, request headers, registered plugin definitions, and endpoint replacement. StackProvider owns browser/framework services (router, auth, notify, and i18n) plus genuine plugin browser customization.

Never serialize the request-specific stack into the browser. Construct the SSR stack with filtered request headers, then construct the browser stack from browser-safe origins and the hydrated QueryClient.

3. Delete manual provider generics and override maps

Before:

type AppPluginOverrides = {
  "ai-chat": AiChatPluginOverrides
  blog: BlogPluginOverrides
}

<StackProvider<AppPluginOverrides>
  basePath="/pages"
  overrides={overrides}
>
  {children}
</StackProvider>

After, the resolved definitions registered in createClientStack({ plugins }) infer the valid override keys and each value shape:

<StackProvider
  stack={clientStack}
  router={router}
  overrides={{ aiChat: { /* AI Chat browser customization */ } }}
>
  {children}
</StackProvider>

Omit overrides entirely when no plugin needs customization. usePluginOverrides() supplies a safe empty value for an omitted optional block; genuinely required plugin customization remains required by the registered definition's inferred type.

4. Rename programmatic plugin IDs to camelCase

Package paths and URL slugs remain kebab-case. Only programmatic registration IDs, provider keys, resource namespaces, and diagnostics change:

Package or URL slugRemoved programmatic IDCanonical programmatic ID
ai-chatai-chataiChat
blogblogblog
cmscmscms
commentscommentscomments
form-builderform-builderformBuilder
kanbankanbankanban
mediamediamedia
open-apiopen-apiopenApi
route-docsroute-docsrouteDocs
ui-builderui-builderuiBuilder
plugins: {
- "ai-chat": aiChatClientPlugin(),
- "form-builder": formBuilderClientPlugin(),
- "route-docs": routeDocsClientPlugin(),
- "ui-builder": uiBuilderClientPlugin(),
+ aiChat: aiChatClientPlugin(),
+ formBuilder: formBuilderClientPlugin(),
+ routeDocs: routeDocsClientPlugin(),
+ uiBuilder: uiBuilderClientPlugin(),
}

5. Use one backend options object with nested hooks

Before:

blogBackendPlugin(blogHooks)
commentsBackendPlugin({ allowEditing: false, onAfterPost })
kanbanBackendPlugin(resolveUser, searchUsers, kanbanHooks)

After:

import type { BlogBackendHooks } from "@btst/stack/plugins/blog/api"
import type { KanbanBackendHooks } from "@btst/stack/plugins/kanban/api"

const blogHooks: BlogBackendHooks = {
  // Use the canonical Blog hook names from the tables below.
}
const kanbanHooks: KanbanBackendHooks = {
  // Use the canonical Kanban hook names from the tables below.
}

blogBackendPlugin({ hooks: blogHooks })
commentsBackendPlugin({
  allowEditing: false,
  resolveUser,
  hooks: { onAfterCreateComment },
})
kanbanBackendPlugin({ hooks: kanbanHooks })

<StackProvider
  stack={clientStack}
  router={router}
  overrides={{ kanban: { resolveUser, searchUsers } }}
>
  {children}
</StackProvider>

Every backend plugin is a factory receiving at most one options object. Optional-only factories allow plugin(). Required plugin configuration stays required on its owning surface: AI Chat backend options keep its model, tools, and access mode; CMS keeps content types; Comments keeps behavior and user resolution; Media keeps storage, tenant, and upload configuration; OpenAPI keeps its presentation/schema options. Kanban user resolution and search are browser services and move to the inferred StackProvider override shown above.

OpenAPI is intentionally backend-only. Route Docs is intentionally client-only. UI Builder is intentionally client-only and composes over the registered CMS backend/client contract; do not invent matching halves for symmetry.

6. Rename every backend lifecycle callback

The tables below come from the structured *_LIFECYCLE_HOOK_MIGRATIONS inventories shipped by each plugin. Names absent from these tables did not change. Keep all callbacks under the plugin factory's hooks field.

AI Chat

Removed nameCanonical name
onBeforeToolsActivatedonBeforeActivateTools
onConversationsReadonAfterListConversations
onConversationReadonAfterGetConversation
onConversationCreatedonAfterCreateConversation
onConversationUpdatedonAfterUpdateConversation
onConversationDeletedonAfterDeleteConversation
onChatErroronErrorChat
onListConversationsErroronErrorListConversations
onGetConversationErroronErrorGetConversation
onCreateConversationErroronErrorCreateConversation
onUpdateConversationErroronErrorUpdateConversation
onDeleteConversationErroronErrorDeleteConversation

Blog

Removed nameCanonical name
onBeforeNextPreviousPostsonBeforeGetNextPreviousPosts
onPostsReadonAfterListPosts
onPostCreatedonAfterCreatePost
onPostUpdatedonAfterUpdatePost
onPostDeletedonAfterDeletePost
onNextPreviousPostsReadonAfterGetNextPreviousPosts
onListPostsErroronErrorListPosts
onNextPreviousPostsErroronErrorGetNextPreviousPosts
onCreatePostErroronErrorCreatePost
onUpdatePostErroronErrorUpdatePost
onDeletePostErroronErrorDeletePost

CMS

Removed nameCanonical name
onBeforeCreateonBeforeCreateContent
onAfterCreateonAfterCreateContent
onBeforeUpdateonBeforeUpdateContent
onAfterUpdateonAfterUpdateContent
onBeforeDeleteonBeforeDeleteContent
onAfterDeleteonAfterDeleteContent
onErroronErrorExecuteContentOperation

Comments

Removed nameCanonical name
onBeforeListonBeforeListComments
onBeforeCountonBeforeCountComments
onBeforeListByAuthoronBeforeListCommentsByAuthor
onBeforePostonBeforeCreateComment
onAfterPostonAfterCreateComment
onBeforeEditonBeforeUpdateComment
onAfterEditonAfterUpdateComment
onBeforeLikeonBeforeToggleCommentReaction
onBeforeStatusChangeonBeforeModerateComment
onAfterApproveonAfterApproveComment
onBeforeDeleteonBeforeDeleteComment
onAfterDeleteonAfterDeleteComment

Form Builder

Removed nameCanonical name
onBeforeFormCreatedonBeforeCreateForm
onAfterFormCreatedonAfterCreateForm
onBeforeFormUpdatedonBeforeUpdateForm
onAfterFormUpdatedonAfterUpdateForm
onBeforeFormDeletedonBeforeDeleteForm
onAfterFormDeletedonAfterDeleteForm
onSubmissionErroronErrorSubmission
onBeforeSubmissionDeletedonBeforeDeleteSubmission
onAfterSubmissionDeletedonAfterDeleteSubmission

Kanban

Removed nameCanonical name
onBeforeReadBoardonBeforeGetBoard
onBoardsReadonAfterListBoards
onBoardReadonAfterGetBoard
onBoardCreatedonAfterCreateBoard
onBoardUpdatedonAfterUpdateBoard
onBoardDeletedonAfterDeleteBoard
onListBoardsErroronErrorListBoards
onReadBoardErroronErrorGetBoard
onCreateBoardErroronErrorCreateBoard
onUpdateBoardErroronErrorUpdateBoard
onDeleteBoardErroronErrorDeleteBoard
onColumnCreatedonAfterCreateColumn
onColumnUpdatedonAfterUpdateColumn
onColumnDeletedonAfterDeleteColumn
onTaskCreatedonAfterCreateTask
onTaskUpdatedonAfterUpdateTask
onTaskDeletedonAfterDeleteTask

Media

Removed nameCanonical name
onBeforeDeleteonBeforeDeleteAsset
onAfterDeleteonAfterDeleteAsset
onOperationErroronError

The lifecycle grammar is onBefore<Action><Entity>, onAfter<Action><Entity>, and onError<Action><Entity>. Meaningful domain events such as chat, submission receipt, moderation approval, and upload finalization retain their domain vocabulary. Media storage-adapter callbacks are transport contracts and are not part of this mapping.

7. Select an explicit server trust surface

Before:

await app.api.blog.updatePost(input)
await app.forRequest(request).api.blog.updatePost(input)
await app.internal.blog.updatePost(input)

After:

await app.forRequest(request).operations.blog.updatePost(input)
await app.trusted.blog.updatePost(input)
await app.raw.blog.prefetchForRoute("post", queryClient, { slug })

forRequest(request).operations runs validation, trusted-fact derivation, configured server authorization, domain behavior, and lifecycle hooks. trusted skips only user authorization and keeps the rest of that operation pipeline. raw is the explicit lower-level escape hatch and first-party plugins expose only narrow SSG prefetch helpers there. Standalone exported getters and mutations are also lower-level primitives whose caller owns validation, authorization, and lifecycle composition.

Omitting createBackendStack({ auth }) preserves permissive compatibility. Once server auth is configured, its schema-bound rules are authoritative and missing or denied rules fail closed. Browser checks remain presentation only; derive authorization facts from server data rather than client input.

8. Apply endpoint and identity boundaries

A path-only endpoints.<plugin>.api replacement inherits the top-level API origin and request headers. A replacement baseURL must include a replacement basePath and establishes a new transport boundary: server cookies, authorization, proxy-authorization, and other request headers are not inherited. Add only explicitly browser-safe browserHeaders or an explicit Fetch credentials mode, and only when the destination implements that plugin's BTST HTTP contract. Site endpoints follow the same path-only versus complete-replacement rule independently.

initialIdentity is tri-state: undefined means no server snapshot was supplied and the client resolver may run immediately; null is an explicitly hydrated anonymous snapshot; an identity object is an explicitly hydrated authenticated snapshot. Serialize only schema-validated identity and trusted deployment origins, never a server stack or request headers.

BTST core is authentication-provider agnostic. Better Auth and Better Auth UI are not core dependencies or hidden identity bridges; adapt the provider your application already uses through createClientAuth and createServerAuth. Applications that already run Better Auth can separately opt into the migrated Better Auth UI companion for auth and account pages.


Migration reference: v2 → v3 framework entries and resolved client runtime

BTST v3 has one supported framework-wiring path: framework entry factories own the catch-all routes, createClientStack() owns shared API, site, and QueryClient runtime, and StackProvider consumes that resolved stack alongside browser-side router and auth services. Plugin factories still own plugin-specific loader and metadata choices; plugin overrides contain only plugin-specific browser 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:

+ 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)
+ 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

Use the equivalent pair for your framework:

FrameworkAPI factoryPage factoryRouter preset
Next.jstoNextRouteHandlerscreateNextPagenextRouter
React RoutertoReactRouterHandlerscreateReactRouterPagereactRouter
TanStack RoutertoTanStackHandlerscreateTanStackPageOptionstanstackRouter

The installation guide has complete route files for all three frameworks.

All three page factories also support request-aware client creation. Next.js awaits getStackClient with the current page props, React Router exposes page.createLoader() with its request and router context, and TanStack accepts an isomorphic getLoaderStackClient resolver with its loader context. Existing synchronous factories remain valid; see the installation guide's request-aware examples when SSR authorization needs headers or session state.

2. Move shared runtime to the client stack

Every maintained client plugin now uses the resolved runtime definition. Remove shared runtime fields from each plugin factory and configure API, site, and QueryClient once in a shared factory:

lib/stack-client.ts
export const getStackClient = (
  queryClient: QueryClient,
  options: { apiOrigin: string; siteOrigin: string },
) => createClientStack({
  api: { baseURL: options.apiOrigin, basePath: "/api/data" },
  site: { baseURL: options.siteOrigin, basePath: "/pages" },
  queryClient,
  plugins: { blog: blogClientPlugin() },
})

Create a request-specific stack for server loaders and metadata:

app/(request)/pages/[[...all]]/page.tsx
import { headers } from "next/headers"
import { getStackClientForRequest } from "@/lib/stack-client.server"

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

Create a separate, stable browser stack inside the Client Component that owns the provider. Do not pass or serialize the request stack; it contains functions and server-only request headers.

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

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

  return (
    <QueryClientProvider client={queryClient}>
      <StackProvider
        stack={clientStack}
        router={nextRouter()}
        auth={clientAuth}
        overrides={{ blog: { uploadImage } }}
      >
        {children}
      </StackProvider>
    </QueryClientProvider>
  )
}

Wrap this client provider from app/(request)/pages/layout.tsx using getServerClientOriginsFromHeaders(await headers()). Put SSG/ISR routes under app/(static)/pages and use header-free getServerClientOrigins() there. Both route groups keep the /pages/* URL.

apiBaseURL, apiBasePath, site fields, queryClient, and request headers are no longer built-in plugin options. SSR loaders, metadata, browser hooks, and mutations use the same resolved runtime for every maintained client plugin.

3. Replace render guards with StackProvider.auth

All onBefore*PageRendered override callbacks were removed. Define exact permission descriptors and bind the shared authorization rule to the browser:

+ import { createClientAuth } from "@btst/stack/authorization/client"
+ import { authorization } from "@/lib/authorization"
+
+ const clientAuth = createClientAuth({
+   authorization,
+   getIdentity: () => session.user,
+   loginPath: "/login",
+ })
+
  <StackProvider
+   auth={clientAuth}
    overrides={{
      blog: {
-       onBeforeDraftsPageRendered: () => Boolean(currentUser),
-       onBeforeNewPostPageRendered: () => currentUser?.role === "admin",
        uploadImage,
      },
    }}
  >

Built-in routes declare schema-backed permission descriptors. The browser uses the same synchronous rule for presentation gates; createServerAuth() is the authoritative request boundary. Lifecycle hooks run afterward for domain validation, side effects, and telemetry—not routine authorization.

4. Remove manual API and identity component props

CommentThread and CommentCount now read API and identity services from the nearest provider:

  <CommentThread
    resourceId={post.slug}
    resourceType="blog-post"
-   apiBaseURL={baseURL}
-   apiBasePath="/api/data"
-   currentUserId={session?.user?.id}
-   headers={headers}
  />

Keep request headers on the top-level createClientStack({ api }) runtime; they are no longer plugin options or public component identity props. The optional loginHref prop remains available for threads that need a resource-specific sign-in return URL and takes precedence over the provider's loginPath.

5. Update parameterized page-component overrides

Parameterized pageComponents now receive the declarative route context { params } instead of named props:

blogClientPlugin({
  // ...Blog-specific SEO, hooks, and page choices
  pageComponents: {
    posts: MyCustomPostsPage,
-   post: ({ slug }) => <MyCustomPostPage slug={slug} />,
+   post: ({ params }) => <MyCustomPostPage slug={params.slug} />,
-   tag: ({ tagSlug }) => <MyCustomTagPage tagSlug={tagSlug} />,
+   tag: ({ params }) => <MyCustomTagPage tagSlug={params.tagSlug} />,
  },
})
PluginOverridev2 propsv3 props
Blogpost, editPost{ slug }{ params: { slug } }
Blogtag{ tagSlug }{ params: { tagSlug } }
CMScontentList, newContent{ typeSlug }{ params: { typeSlug } }
CMSeditContent{ typeSlug, id }{ params: { typeSlug, id } }
Form BuildereditForm{ id }{ params: { id } }
Form Buildersubmissions{ formId }{ params: { id } }
UI BuildereditPage{ id }{ params: { id } }
Kanbanboard{ boardId }{ params: { boardId } }
AI ChatchatConversation{ conversationId }{ params: { id } }

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

The boolean-return compatibility shim was removed. Returning false no longer denies a request:

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
}

7. Rename normalized backend lifecycle hooks

Use the complete seven-plugin mapping in Rename every backend lifecycle callback above. JavaScript applications must update removed keys too; removed callback names are not invoked at runtime. Keep hook denials exception-based while renaming them.

8. Replace the v2 provider-specific auth bridge

The v3 CLI no longer generates the old Better Auth-to-BTST authorization provider. BTST authorization is provider-agnostic: adapt your application session through the generic client and server identity resolvers.

The stable-v3 CLI does offer an optional better-auth-ui companion scaffold for applications that already own a Better Auth server. That selection creates only the Better Auth browser client plus auth and account UI routes; it does not generate the server, database, schema, migrations, providers, secrets, organization plugin, or a BTST identity bridge.

Remove the old bridge:

npx @btst/codegen init --plugins blog,better-auth-ui
import { createBetterAuthProvider } from "@btst/better-auth-ui"

<StackProvider auth={createBetterAuthProvider(authClient)}>
  {children}
</StackProvider>

Keep authorization application-owned:

authorization.client.ts
export const clientAuth = createClientAuth({
  authorization,
  getIdentity: () => session?.user ?? null,
  loginPath: "/sign-in",
})
authorization.server.ts
export const serverAuth = createServerAuth({
  authorization,
  getIdentity: ({ headers }) => myAuthProvider.getIdentity(headers),
})

Pass clientAuth to StackProvider, pass serverAuth to createBackendStack({ auth: serverAuth }), and keep authorization independent from the optional Better Auth UI routes. See Better Auth UI Companion for the supported scaffold and exact dependency cohort.

Migration checklist

  • Replace API and page catch-all glue with the framework entry factories.
  • Configure API, site, and QueryClient once on createClientStack(), then pass its browser-resolved stack to StackProvider.stack.
  • Add one framework router preset 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, and identity props from Comments components; keep loginHref only when a thread needs a per-instance sign-in URL.
  • Update parameterized pageComponents adapters to read params.
  • Change backend hook denials from boolean returns to thrown errors.
  • Replace every retired Form Builder, Kanban, and Media lifecycle spelling using the RC3 mapping tables.
  • Keep the Better Auth backend and identity resolvers application-owned. When selected, use the optional companion scaffold for browser auth/account routes and connect it to that existing backend.
  • 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.

This is a breaking change that requires updates to imports, function names, and file references. The functionality remains the same.

Summary of Changes

v1 (Better Stack)v2 (BTST)
betterStack()stack()
betterStackClient()stackClient()
BetterStackProviderStackProvider
useBetterStackuseStack
BetterStackAttributionStackAttribution
better-stack.tsstack.ts
better-stack-client.tsxstack-client.tsx
"Powered by Better Stack""Powered by BTST"

Migration Steps

Update Package

Update your @btst/stack package to v2:

npm install @btst/stack@latest
# or
pnpm add @btst/stack@latest

Rename Backend Setup Function

Update your backend configuration file (commonly lib/better-stack.ts → lib/stack.ts):

- import { betterStack } from "@btst/stack";
+ import { stack } from "@btst/stack";

- export const { api, clientConfig, generateSitemap } = betterStack({
+ export const { api, clientConfig, generateSitemap } = stack({
    // ... your configuration
  });

Rename Client Setup Function

Update your client configuration file (commonly lib/better-stack-client.tsx → lib/stack-client.tsx):

- import { betterStackClient } from "@btst/stack/client";
+ import { stackClient } from "@btst/stack/client";

- export const { PluginPages, routes, loaders, metas } = betterStackClient({
+ export const { PluginPages, routes, loaders, metas } = stackClient({
    // ... your configuration
  });

Update Provider Component

If you're using the provider component directly:

- import { BetterStackProvider } from "@btst/stack/client";
+ import { StackProvider } from "@btst/stack/client";

function App() {
  return (
-   <BetterStackProvider overrides={overrides}>
+   <StackProvider overrides={overrides}>
      {children}
-   </BetterStackProvider>
+   </StackProvider>
  );
}

Update Hook Imports

If you're using the context hook directly:

- import { useBetterStack } from "@btst/stack/client";
+ import { useStack } from "@btst/stack/client";

function MyComponent() {
-   const context = useBetterStack();
+   const context = useStack();
    // ...
}

Update Attribution Component

If you're using the attribution component:

- import { BetterStackAttribution } from "@workspace/ui/components/better-stack-attribution";
+ import { StackAttribution } from "@workspace/ui/components/stack-attribution";

- <BetterStackAttribution />
+ <StackAttribution />

Update File Imports

Update any imports that reference the old filenames:

- import { api } from "@/lib/better-stack";
+ import { api } from "@/lib/stack";

- import { PluginPages } from "@/lib/better-stack-client";
+ import { PluginPages } from "@/lib/stack-client";

For consistency, rename your configuration files:

# Backend config
mv lib/better-stack.ts lib/stack.ts

# Client config  
mv lib/better-stack-client.tsx lib/stack-client.tsx

# Auth config (if applicable)
mv lib/better-stack-auth.ts lib/stack-auth.ts

Find and Replace

You can use these find-and-replace patterns to quickly update your codebase:

FindReplace
betterStack(stack(
betterStackClient(stackClient(
BetterStackProviderStackProvider
useBetterStackuseStack
BetterStackAttributionStackAttribution
better-stack-attributionstack-attribution
from "@/lib/better-stack"from "@/lib/stack"
from "@/lib/better-stack-client"from "@/lib/stack-client"
from "./better-stack"from "./stack"
from "./better-stack-client"from "./stack-client"

TypeScript Types

The following types have been renamed:

- import type { BetterStackContext } from "@btst/stack";
+ import type { StackContext } from "@btst/stack";

Localization Strings

If you've customized localization, update any references:

- "Powered by Better Stack"
+ "Powered by BTST"

No Functional Changes

All plugin functionality, APIs, database schemas, and component behavior remain unchanged. This is purely a naming/branding update.

The following are unchanged:

  • All plugin configurations and options
  • Database schemas and adapters
  • API endpoints and routes
  • Component props and behavior
  • Hook return values and options
  • SSR, meta tags, and sitemap generation

Need Help?

If you encounter any issues during migration, please open an issue on GitHub.

On this page

v3 → v4: Better Auth 1.7 and Better Auth UI 1.7v2 or release candidate → stable v3: production playbook0. Pin the verified cohort and a rollback point1. Apply the ownership changes in this order2. Register plugins, factories, and lifecycle hooks3. Configure atomic writes explicitly4. Migrate authorization as one application-owned rule5. Adopt framework entries and tri-state hydration6. Migrate embedded surfaces and every provider root7. Regenerate or merge the framework scaffold8. Migrate the Better Auth UI companion9. Verify data, production behavior, and cleanupMigration reference: v3 RC2 → RC3 canonical stack and plugin DX1. Rename both stack constructors2. Move shared client runtime to one resolved stack3. Delete manual provider generics and override maps4. Rename programmatic plugin IDs to camelCase5. Use one backend options object with nested hooks6. Rename every backend lifecycle callbackAI ChatBlogCMSCommentsForm BuilderKanbanMedia7. Select an explicit server trust surface8. Apply endpoint and identity boundariesMigration reference: v2 → v3 framework entries and resolved client runtime1. Replace hand-written catch-all routes with entry factories2. Move shared runtime to the client stack3. Replace render guards with StackProvider.auth4. Remove manual API and identity component props5. Update parameterized page-component overrides6. Deny backend hooks by throwing7. Rename normalized backend lifecycle hooks8. Replace the v2 provider-specific auth bridgeMigration checklistv1 → v2: Rebranding to BTSTSummary of ChangesMigration StepsUpdate PackageRename Backend Setup FunctionRename Client Setup FunctionUpdate Provider ComponentUpdate Hook ImportsUpdate Attribution ComponentUpdate File ImportsRename Files (Optional but Recommended)Find and ReplaceTypeScript TypesLocalization StringsNo Functional ChangesNeed Help?