Thank you for your interest in contributing. This guide covers all contribution types — from quick doc fixes to full new plugins.
- Development environment
- Types of contributions
- Plugin development guide
- Testing
- Documentation
- Submission checklist
Node.js v22 is required. Always activate it before running any commands:
source ~/.nvm/nvm.sh && nvm use 22Install dependencies and verify the build:
pnpm install
pnpm build # build all packages
pnpm typecheck # TypeScript type check
pnpm lint # Biome lint + format checkOpen an issue at github.com/better-stack-ai/better-stack/issues and include:
- A minimal reproduction (a demo link or a code snippet)
- The framework you are using (Next.js / React Router / TanStack)
- The
@btst/stackversion - What you expected vs. what happened
Documentation lives in docs/content/docs/. MDX files are served by FumaDocs.
cd docs
pnpm dev # preview the docs site locallyEdit the relevant .mdx file under docs/content/docs/plugins/ and open a PR. No build step is required for doc-only changes — just verify pnpm dev renders correctly.
The three example apps live in examples/nextjs/, examples/react-router/, and examples/tanstack/. Each is a self-contained Next.js / React Router / TanStack app that demos all built-in plugins.
To run an example locally:
cd examples/nextjs # or react-router, tanstack
pnpm install
pnpm devSee the full plugin development guide below.
If you want to publish a plugin as a standalone npm package (not merged into this repo), use the Plugin Starter — it provides a pre-configured build, example app, and CI pipeline.
A plugin has two halves that must be kept in sync:
| Half | Entry point | Factory function | Import path |
|---|---|---|---|
| Backend | api/plugin.ts |
defineBackendPlugin |
@btst/stack/plugins/api |
| Client | client/plugin.tsx |
defineClientPlugin |
@btst/stack/plugins/client |
Minimum backend shape:
import { defineBackendPlugin, createDbPlugin, createEndpoint, type Adapter } from "@btst/stack/plugins/api"
export const myBackendPlugin = defineBackendPlugin({
name: "my-plugin", // unique key — must match the key used in stack({ plugins: { ... } })
dbPlugin: mySchema, // from createDbPlugin(...)
routes: (adapter: Adapter) => {
const listItems = createEndpoint("/items", { method: "GET" }, async () => {
return adapter.findMany({ model: "item" })
})
return { listItems } as const
},
// Optional: server-side API surface (no HTTP roundtrip — used for SSG, scripts, Server Components)
api: (adapter: Adapter) => ({
listItems: () => adapter.findMany({ model: "item" }),
}),
})
// Export the inferred router type — the client plugin imports this for end-to-end type safety
export type MyApiRouter = ReturnType<typeof myBackendPlugin.routes>Minimum client shape:
import { defineClientPlugin, createRoute, createApiClient } from "@btst/stack/plugins/client"
import { lazy } from "react"
import type { QueryClient } from "@tanstack/react-query"
import type { MyApiRouter } from "../api/plugin"
export interface MyClientConfig {
queryClient: QueryClient
apiBaseURL: string
apiBasePath: string
siteBaseURL: string
siteBasePath: string
}
export const myClientPlugin = (config: MyClientConfig) =>
defineClientPlugin({
name: "my-plugin",
routes: () => ({
list: createRoute("/my-plugin", () => {
const ListPage = lazy(() => import("./components/list-page"))
return {
PageComponent: ListPage,
loader: myLoader(config),
meta: myMeta(config),
}
}),
}),
})Backend hook naming conventions:
// Authorization hooks (throw to deny)
onBeforeCreate, onBeforeUpdate, onBeforeDelete, onBeforeList
// Lifecycle hooks (called after success)
onAfterCreate, onAfterUpdate, onAfterDelete, onAfterList
// Error hooks
onCreateError, onUpdateError, onDeleteError, onListErrorThe blog plugin at packages/stack/src/plugins/blog/ is the canonical reference implementation. Use this layout for a new plugin:
packages/stack/src/plugins/your-plugin/
├── db.ts # createDbPlugin(...) — database schema definition
├── types.ts # Shared TypeScript types (no framework dependencies)
├── schemas.ts # Zod validation schemas for request bodies
├── query-keys.ts # React Query key factory (imports from api/query-key-defs.ts)
├── client.css # Plugin CSS (Tailwind source directives, component styles)
├── style.css # Full styles including Tailwind @source directives
├── api/
│ ├── plugin.ts # defineBackendPlugin, RouteKey type, prefetchForRoute factory
│ ├── getters.ts # Pure DB read functions — no hooks, no HTTP context
│ ├── mutations.ts # Server-side write functions — no hooks, no HTTP context
│ ├── query-key-defs.ts # Shared query key shapes (prevents SSG/SSR key drift)
│ ├── serializers.ts # Convert Date fields → ISO strings before setQueryData
│ └── index.ts # Barrel re-export of all public backend surface
└── client/
├── plugin.tsx # defineClientPlugin — routes, loaders, meta generators
├── overrides.ts # YourPluginOverrides interface
├── index.ts # Public client entry point
├── hooks/
│ └── index.tsx # useSuspenseQuery-based React Query hooks
└── components/
└── pages/
├── list-page.tsx # PageComponent wrapper (ComposedRoute + lazy)
└── list-page.internal.tsx # Actual page content (useSuspenseQuery inside)
Not every file is required for a minimal plugin. Start with db.ts, types.ts, api/plugin.ts, and client/plugin.tsx. Add the rest as the plugin grows.
Define your data models using createDbPlugin. Field types: string, boolean, number, date.
// packages/stack/src/plugins/your-plugin/db.ts
import { createDbPlugin } from "@btst/stack/plugins/api"
export const mySchema = createDbPlugin("your-plugin", {
item: {
modelName: "item",
fields: {
title: { type: "string", required: true },
published: { type: "boolean", defaultValue: false },
createdAt: { type: "date", defaultValue: () => new Date() },
updatedAt: { type: "date", defaultValue: () => new Date() },
},
},
})// packages/stack/src/plugins/your-plugin/types.ts
export type Item = {
id: string
title: string
published: boolean
createdAt: Date
updatedAt: Date
}// packages/stack/src/plugins/your-plugin/schemas.ts
import { z } from "zod"
export const createItemSchema = z.object({
title: z.string().min(1),
published: z.boolean().optional().default(false),
})
export const updateItemSchema = createItemSchema.partial()api/getters.ts — pure DB reads, safe for SSG and scripts. Authorization hooks are not called here — callers are responsible for access control.
// packages/stack/src/plugins/your-plugin/api/getters.ts
import type { Adapter } from "@btst/stack/plugins/api"
import type { Item } from "../types"
/** Returns all items sorted newest-first. Authorization hooks are NOT called. */
export async function listItems(adapter: Adapter): Promise<Item[]> {
return adapter.findMany<Item>({
model: "item",
sortBy: { field: "createdAt", direction: "desc" },
}) as Promise<Item[]>
}
/** Returns a single item by ID, or null. Authorization hooks are NOT called. */
export async function getItemById(adapter: Adapter, id: string): Promise<Item | null> {
return adapter.findOne<Item>({
model: "item",
where: [{ field: "id", value: id, operator: "eq" }],
})
}api/mutations.ts — server-side writes. Keep separate from getters. JSDoc warning is required.
// packages/stack/src/plugins/your-plugin/api/mutations.ts
import type { Adapter } from "@btst/stack/plugins/api"
import type { Item } from "../types"
export interface CreateItemInput { title: string }
/**
* Create an item directly in the database.
*
* @remarks Authorization hooks are NOT called. The caller is responsible for
* access-control checks before invoking this function.
*/
export async function createItem(adapter: Adapter, input: CreateItemInput): Promise<Item> {
return adapter.create<Item>({
model: "item",
data: { ...input, published: false, createdAt: new Date(), updatedAt: new Date() },
})
}api/plugin.ts — the main backend plugin definition:
// packages/stack/src/plugins/your-plugin/api/plugin.ts
import { defineBackendPlugin, createEndpoint, type Adapter } from "@btst/stack/plugins/api"
import { mySchema } from "../db"
import { createItemSchema, updateItemSchema } from "../schemas"
import { listItems, getItemById } from "./getters"
export interface MyBackendHooks {
onBeforeCreate?: (data: unknown, ctx: { headers: Headers }) => Promise<void> | void
onAfterCreate?: (item: unknown, ctx: { headers: Headers }) => Promise<void> | void
onCreateError?: (error: Error, ctx: { headers: Headers }) => Promise<void> | void
}
export const myBackendPlugin = (hooks?: MyBackendHooks) =>
defineBackendPlugin({
name: "your-plugin",
dbPlugin: mySchema,
api: (adapter) => ({
listItems: () => listItems(adapter),
getItemById: (id: string) => getItemById(adapter, id),
}),
routes: (adapter: Adapter) => {
const listItemsEndpoint = createEndpoint("/items", { method: "GET" }, async () => {
return listItems(adapter)
})
const createItemEndpoint = createEndpoint(
"/items",
{ method: "POST", body: createItemSchema },
async (ctx) => {
if (hooks?.onBeforeCreate) {
try {
await hooks.onBeforeCreate(ctx.body, { headers: ctx.headers })
} catch (e) {
throw ctx.error(403, { message: e instanceof Error ? e.message : "Unauthorized" })
}
}
const item = await adapter.create({ model: "item", data: { ...ctx.body, createdAt: new Date(), updatedAt: new Date() } })
await hooks?.onAfterCreate?.(item, { headers: ctx.headers })
return item
},
)
const updateItemEndpoint = createEndpoint(
"/items/:id",
{ method: "PUT", body: updateItemSchema },
async (ctx) => {
const updated = await adapter.update({
model: "item",
where: [{ field: "id", value: ctx.params.id }],
update: { ...ctx.body, updatedAt: new Date() },
})
if (!updated) throw ctx.error(404, { message: "Item not found" })
return updated
},
)
const deleteItemEndpoint = createEndpoint("/items/:id", { method: "DELETE" }, async (ctx) => {
await adapter.delete({ model: "item", where: [{ field: "id", value: ctx.params.id }] })
return { success: true }
})
return { listItemsEndpoint, createItemEndpoint, updateItemEndpoint, deleteItemEndpoint } as const
},
})
export type MyApiRouter = ReturnType<ReturnType<typeof myBackendPlugin>["routes"]>api/index.ts — barrel re-export:
// packages/stack/src/plugins/your-plugin/api/index.ts
export * from "./plugin"
export { listItems, getItemById } from "./getters"
export { createItem, type CreateItemInput } from "./mutations"SSR loader — prefetch data on the server. Always check isConnectionError in the catch block so a build-time next build failure is clearly signposted rather than silently producing an empty page.
// packages/stack/src/plugins/your-plugin/client/plugin.tsx
import { defineClientPlugin, createRoute, createApiClient, isConnectionError } from "@btst/stack/plugins/client"
import { lazy } from "react"
import type { QueryClient } from "@tanstack/react-query"
import type { MyApiRouter } from "../api/plugin"
export interface MyClientConfig {
queryClient: QueryClient
apiBaseURL: string
apiBasePath: string
siteBaseURL: string
siteBasePath: string
}
function myLoader(config: MyClientConfig) {
return async () => {
if (typeof window === "undefined") {
const { queryClient, apiBaseURL, apiBasePath } = config
try {
const client = createApiClient<MyApiRouter>({ baseURL: apiBaseURL, basePath: apiBasePath })
await queryClient.prefetchQuery({
queryKey: ["your-plugin", "items"],
queryFn: async () => (await client("/items", { method: "GET" })).data,
})
} catch (error) {
if (isConnectionError(error)) {
console.warn(
"[btst/your-plugin] route.loader() failed — no server at build time. " +
"Use myStack.api['your-plugin'].prefetchForRoute() for SSG.",
)
}
// Do not re-throw — let React Query store errors and Error Boundaries handle them during render
}
}
}
}
function myMeta(config: MyClientConfig) {
return () => {
const { siteBaseURL, siteBasePath } = config
return [
{ title: "My Plugin" },
{ name: "description", content: "My plugin description." },
{ property: "og:url", content: `${siteBaseURL}${siteBasePath}/your-plugin` },
]
}
}
export const myClientPlugin = (config: MyClientConfig) =>
defineClientPlugin({
name: "your-plugin",
routes: () => ({
list: createRoute("/your-plugin", () => {
const ListPage = lazy(() =>
import("./components/pages/list-page").then((m) => ({ default: m.ListPageComponent })),
)
return {
PageComponent: ListPage,
loader: myLoader(config),
meta: myMeta(config),
}
}),
}),
sitemap: async () => [
{ url: `${config.siteBaseURL}${config.siteBasePath}/your-plugin`, lastModified: new Date(), priority: 0.7 },
],
})Page component wrapper (list-page.tsx) — wraps with ComposedRoute for Suspense + ErrorBoundary:
// packages/stack/src/plugins/your-plugin/client/components/pages/list-page.tsx
"use client"
import { lazy } from "react"
import { ComposedRoute } from "@btst/stack/client/components"
// Lazy-load the actual page content — enables code splitting
const ListPageInternal = lazy(() =>
import("./list-page.internal").then((m) => ({ default: m.ListPageInternal })),
)
function ListPageSkeleton() {
return <div className="animate-pulse h-32 bg-muted rounded" />
}
export function ListPageComponent() {
return (
<ComposedRoute
path="/your-plugin"
PageComponent={ListPageInternal}
LoadingComponent={ListPageSkeleton}
ErrorComponent={({ error, resetErrorBoundary }) => (
<div>
<p>Something went wrong: {error.message}</p>
<button onClick={resetErrorBoundary}>Retry</button>
</div>
)}
NotFoundComponent={({ message }) => <div>Not found: {message}</div>}
/>
)
}Internal page (list-page.internal.tsx) — uses useSuspenseQuery, throws on refetch errors:
// packages/stack/src/plugins/your-plugin/client/components/pages/list-page.internal.tsx
"use client"
import { useSuspenseQuery } from "@tanstack/react-query"
import { createApiClient } from "@btst/stack/plugins/client"
import type { MyApiRouter } from "../../../api/plugin"
export function ListPageInternal() {
const client = createApiClient<MyApiRouter>({ baseURL: "/api/data" })
const { data, error, isFetching } = useSuspenseQuery({
queryKey: ["your-plugin", "items"],
queryFn: async () => (await client("/items", { method: "GET" })).data,
staleTime: 60_000,
retry: false,
})
// useSuspenseQuery only throws on the initial fetch — manually rethrow on refetch failure
// so the parent ErrorBoundary catches it
if (error && !isFetching) throw error
return (
<ul>
{data?.map((item) => <li key={item.id}>{item.title}</li>)}
</ul>
)
}Create a shared key definition file to prevent the SSR and SSG paths from drifting out of sync:
// packages/stack/src/plugins/your-plugin/api/query-key-defs.ts
export const YOUR_PLUGIN_QUERY_KEYS = {
list: () => ["your-plugin", "items", "list"] as const,
detail: (id: string) => ["your-plugin", "items", "detail", id] as const,
}Import YOUR_PLUGIN_QUERY_KEYS in both query-keys.ts (HTTP client path) and prefetchForRoute (DB path) — never define the key shape in two places.
The .tsx / .internal.tsx split is important:
| File | Rendered where | What goes in it |
|---|---|---|
list-page.tsx |
Server + Client | ComposedRoute wrapper with lazy(), Suspense/Error/NotFound components |
list-page.internal.tsx |
Client only | useSuspenseQuery calls, actual UI, mutable state |
Loading fallbacks must be provided to ComposedRoute unconditionally on both server and client — never guard them with typeof window !== "undefined". Doing so shifts React's useId() counter and causes hydration mismatches in Radix primitives (Select, Dialog, etc.).
First-party plugins inside packages/stack/ must be registered in two files.
packages/stack/build.config.ts — add entries for each export path:
entries: [
// ... existing entries
// your-plugin entries
"./src/plugins/your-plugin/api/index.ts",
"./src/plugins/your-plugin/client/index.ts",
"./src/plugins/your-plugin/client/hooks/index.tsx",
"./src/plugins/your-plugin/client/components/index.tsx",
"./src/plugins/your-plugin/query-keys.ts",
]packages/stack/package.json — add both exports and typesVersions for every entry point:
{
"exports": {
"./plugins/your-plugin/api": {
"import": "./dist/plugins/your-plugin/api/index.mjs",
"require": "./dist/plugins/your-plugin/api/index.cjs"
},
"./plugins/your-plugin/client": {
"import": "./dist/plugins/your-plugin/client/index.mjs",
"require": "./dist/plugins/your-plugin/client/index.cjs"
},
"./plugins/your-plugin/css": "./dist/plugins/your-plugin/client.css"
},
"typesVersions": {
"*": {
"plugins/your-plugin/api": ["./dist/plugins/your-plugin/api/index.d.ts"],
"plugins/your-plugin/client": ["./dist/plugins/your-plugin/client/index.d.ts"]
}
}
}CSS — if your plugin ships UI components, add the CSS export entry to packages/stack/package.json ("./plugins/your-plugin/css": "./dist/plugins/your-plugin/client.css"). CSS files are auto-discovered and copied by postbuild.cjs — no manual registration needed.
All three example apps must be updated when a new first-party plugin is added:
| App | Files to update |
|---|---|
examples/nextjs/ |
lib/stack.tsx, lib/stack-client.tsx, app/pages/[[...all]]/layout.tsx, app/globals.css |
examples/react-router/ |
app/lib/stack.tsx, app/lib/stack-client.tsx, app/routes/pages/_layout.tsx, app/app.css |
examples/tanstack/ |
src/lib/stack.tsx, src/lib/stack-client.tsx, src/routes/pages/route.tsx, src/styles/app.css |
In each layout file, add your plugin's overrides type:
import type { MyPluginOverrides } from "@btst/stack/plugins/your-plugin/client"
type PluginOverrides = {
blog: BlogPluginOverrides,
"your-plugin": MyPluginOverrides, // add your plugin here
}In each CSS file:
@import "@btst/stack/plugins/your-plugin/css";Place unit tests at packages/stack/src/plugins/your-plugin/__tests__/. The pattern from packages/stack/src/plugins/blog/__tests__/getters.test.ts:
import { describe, it, expect, beforeEach } from "vitest"
import { createMemoryAdapter } from "@btst/adapter-memory"
import { defineDb } from "@btst/db"
import { mySchema } from "../db"
import { listItems, getItemById } from "../api/getters"
const createTestAdapter = () => {
const db = defineDb({}).use(mySchema)
return createMemoryAdapter(db)({})
}
describe("your-plugin getters", () => {
let adapter: ReturnType<typeof createTestAdapter>
beforeEach(() => {
adapter = createTestAdapter()
})
it("returns empty list when no items exist", async () => {
const items = await listItems(adapter)
expect(items).toEqual([])
})
it("returns item by id", async () => {
const created = await adapter.create({
model: "item",
data: { title: "Hello", published: false, createdAt: new Date(), updatedAt: new Date() },
})
const found = await getItemById(adapter, created.id)
expect(found?.title).toBe("Hello")
})
})Run unit tests from the package root:
cd packages/stack
pnpm testAdd a smoke test file at e2e/tests/smoke.your-plugin.spec.ts. The pattern from e2e/tests/smoke.blog.spec.ts:
import { test, expect } from "@playwright/test"
test.describe("Your Plugin", () => {
test("list page loads and shows items", async ({ page, request }) => {
const errors: string[] = []
page.on("console", (msg) => {
if (msg.type() === "error") errors.push(msg.text())
})
// Seed test data via the API
await request.post("/api/data/items", {
data: { title: "Test Item" },
})
await page.goto("/pages/your-plugin", { waitUntil: "networkidle" })
await expect(page.locator('[data-testid="your-plugin-list"]')).toBeVisible()
await expect(page.getByText("Test Item")).toBeVisible()
expect(errors).toHaveLength(0)
})
})Run the full E2E suite (starts all three example apps):
cd e2e
export $(cat ../examples/nextjs/.env | xargs)
pnpm e2e:smokeRun against a single framework only (starts only that framework's server — faster):
pnpm e2e:smoke:nextjs
pnpm e2e:smoke:tanstack
pnpm e2e:smoke:react-routerRun a single test file:
pnpm e2e:smoke -- tests/smoke.your-plugin.spec.tsRun against a specific Playwright project:
pnpm e2e:smoke -- --project="nextjs:memory"Tests run against three Playwright projects: nextjs:memory (port 3003), tanstack:memory (3004), react-router:memory (3005). In CI, each framework runs as a separate parallel job via a matrix strategy.
Create docs/content/docs/plugins/your-plugin.mdx. Use AutoTypeTable to render TypeScript interfaces directly from source — this means JSDoc comments on exported types become the docs.
---
title: Your Plugin
description: Brief description for SEO and the docs sidebar
---
Learn how to set up the Your Plugin.
## Installation
\`\`\`bash
npm install @btst/stack
\`\`\`
## Configuration
<AutoTypeTable path="packages/stack/src/plugins/your-plugin/client/overrides.ts" name="MyPluginOverrides" />
## Hooks
<AutoTypeTable path="packages/stack/src/plugins/your-plugin/api/plugin.ts" name="MyBackendHooks" />Preview locally:
cd docs
pnpm devUpdate docs whenever you make consumer-facing changes — new props, new hooks, changed behavior, or breaking changes.
Plugin page components are published as a shadcn v4 registry so consumers can eject and customize the UI layer.
# Blog pages (replace "blog" with any plugin name)
npx shadcn@latest add "https://raw.githubusercontent.com/better-stack-ai/better-stack/main/packages/stack/registry/btst-blog.json"Files are installed into src/components/btst/{plugin}/client/ with all relative imports preserved. Data-fetching hooks remain in @btst/stack.
pnpm --filter @btst/stack build-registryOutput goes to packages/stack/registry/. These files are committed and must be regenerated whenever plugin UI components change.
pnpm --filter @btst/stack test-registryThis builds the registry, packs @btst/stack, spins up a blank Next.js project, installs every plugin via shadcn add, and runs npm run build to confirm it compiles.
The GitHub Actions workflow (.github/workflows/registry.yml) runs this automatically on PRs that touch plugin source files and auto-commits updated registry JSON if anything changed.
- Add a
PluginConfigentry to thePLUGINSarray inpackages/stack/scripts/build-registry.ts. - Run
pnpm --filter @btst/stack build-registryto regenerate the JSONs. - Run
pnpm --filter @btst/stack test-registrylocally to validate end-to-end. - Commit the updated registry files alongside your plugin changes.
Before opening a pull request for a new plugin, verify every item:
Plugin implementation
- Backend plugin:
name,dbPlugin, androutesare all present - Client plugin:
nameandroutesare present -
api/getters.tscontains only pure DB reads — no HTTP context, no lifecycle hooks -
api/getters.tshas JSDoc noting "Authorization hooks are NOT called" -
api/mutations.ts(if present) has JSDoc noting "Authorization hooks are NOT called" -
api/index.tsre-exports all public backend surface (getters, mutations, types, router type) -
api/query-key-defs.tsdefines shared key shapes imported by bothquery-keys.tsandprefetchForRoute -
api/serializers.tsconvertsDatefields to ISO strings beforesetQueryData - Every loader
catchblock callsisConnectionErrorand logs a build-time warning - All page components are wrapped with
ComposedRoute - Loading fallbacks are provided unconditionally (not guarded by
typeof window) -
useSuspenseQueryhooks rethrow on refetch errors:if (error && !isFetching) throw error
Build
-
packages/stack/build.config.ts— entries added for each new export path -
packages/stack/package.json—exportsandtypesVersionsadded for each entry - CSS exported in
package.jsonif the plugin ships UI components (postbuild.cjsauto-discovers CSS files — no changes needed there) -
pnpm buildpasses with no errors
Type checking and linting
-
pnpm typecheckpasses -
pnpm lintpasses
Example apps
-
examples/nextjs/— stack, stack-client, layout, CSS updated -
examples/react-router/— stack, stack-client, layout, CSS updated -
examples/tanstack/— stack, stack-client, layout, CSS updated
Tests
- Unit tests added at
packages/stack/src/plugins/your-plugin/__tests__/ - E2E smoke test added at
e2e/tests/smoke.your-plugin.spec.ts -
pnpm testpasses (unit tests) -
pnpm e2e:smoke -- tests/smoke.your-plugin.spec.tspasses
Documentation
-
docs/content/docs/plugins/your-plugin.mdxcreated - All exported types and interfaces have JSDoc comments
-
cd docs && pnpm devrenders without errors
| Complexity | Plugin | Source |
|---|---|---|
| Simple (CRUD) | Todo plugin | examples/nextjs/lib/plugins/todo/ |
| Full-featured | Blog plugin | packages/stack/src/plugins/blog/ |