Skip to content

Latest commit

 

History

History
877 lines (674 loc) · 29.3 KB

File metadata and controls

877 lines (674 loc) · 29.3 KB

Contributing to BTST

Thank you for your interest in contributing. This guide covers all contribution types — from quick doc fixes to full new plugins.


Table of contents


Development environment

Node.js v22 is required. Always activate it before running any commands:

source ~/.nvm/nvm.sh && nvm use 22

Install dependencies and verify the build:

pnpm install
pnpm build        # build all packages
pnpm typecheck    # TypeScript type check
pnpm lint         # Biome lint + format check

Types of contributions

Bug reports

Open 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/stack version
  • What you expected vs. what happened

Documentation

Documentation lives in docs/content/docs/. MDX files are served by FumaDocs.

cd docs
pnpm dev   # preview the docs site locally

Edit 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.

Local development and testing

The monorepo uses codegen projects as the primary development and testing environment. These projects are built from scratch by the btst init CLI and are never committed to git.

# Build the Next.js codegen project (takes ~2–3 min)
bash scripts/codegen/setup-nextjs.sh

# Start the dev server for browsing and debugging
pnpm -F nextjs dev

# Run E2E tests against it
pnpm -F e2e codegen:e2e:nextjs

# Clean up when done or to start fresh
bash scripts/codegen/cleanup.sh nextjs

See scripts/codegen/README.md for detailed instructions on the E2E overlay file workflow and troubleshooting.

New plugins

See 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.


Plugin development guide

Plugin anatomy

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, onListError

File structure template

The 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.


1. Database schema

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() },
    },
  },
})

2. Shared types and Zod schemas

// 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()

3. Backend plugin

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"

4. Client plugin

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

5. Query keys

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.


6. Page components

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


7. Build configuration

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.


8. Register the plugin in the CLI and codegen project

When a new first-party plugin is added, update the CLI constants so btst init knows about it:

packages/cli/src/utils/constants.ts — add a PluginMeta entry to the PLUGINS array with the plugin's import paths, symbols, and config key.

Then register it in the codegen project overlay files:

scripts/codegen/files/nextjs/lib/stack.ts — add the backend plugin registration.

scripts/codegen/files/nextjs/lib/stack-client.tsx — add the client plugin registration.

scripts/codegen/files/nextjs/app/pages/layout.tsx — add the StackProvider override entry.

Add the plugin CSS to app/globals.css if it ships styles:

@import "@btst/stack/plugins/your-plugin/css";

To apply and test your changes:

bash scripts/codegen/cleanup.sh nextjs
bash scripts/codegen/setup-nextjs.sh
pnpm -F e2e codegen:e2e:nextjs

Testing

Unit tests (Vitest)

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 test

E2E tests (Playwright)

Add 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 E2E suite against the codegen project (primary):

# Build the codegen project first (one-time)
bash scripts/codegen/setup-nextjs.sh

cd e2e
pnpm codegen:e2e:nextjs

# Run a single test file
pnpm codegen:e2e:nextjs -- tests/smoke.your-plugin.spec.ts

Tests run against nextjs:codegen (port 3006). CI runs the full suite via .github/workflows/codegen-e2e.yml, which builds the codegen project from scratch on every run.


Documentation

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 dev

Update docs whenever you make consumer-facing changes — new props, new hooks, changed behavior, or breaking changes.


Shadcn registry

Plugin page components are published as a shadcn v4 registry so consumers can eject and customize the UI layer.

Install a plugin's pages

# 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.

When a plugin exposes pageComponents on its client config, wire the ejected routable pages back in through that option. If a plugin intentionally does not support pageComponents, document the direct-import rendering pattern clearly in the plugin docs and the shared shadcn registry guide.

Rebuild the registry locally

pnpm --filter @btst/stack build-registry

Output goes to packages/stack/registry/. These files are committed and must be regenerated whenever plugin UI components change.

Run the end-to-end registry test

pnpm --filter @btst/stack test-registry

This 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.

Adding a new plugin to the registry

  1. Add a PluginConfig entry to the PLUGINS array in packages/stack/scripts/build-registry.ts.
  2. Run pnpm --filter @btst/stack build-registry to regenerate the JSONs.
  3. Run pnpm --filter @btst/stack test-registry locally to validate end-to-end.
  4. Commit the updated registry files alongside your plugin changes.

Submission checklist

Before opening a pull request for a new plugin, verify every item:

Plugin implementation

  • Backend plugin: name, dbPlugin, and routes are all present
  • Client plugin: name and routes are present
  • api/getters.ts contains only pure DB reads — no HTTP context, no lifecycle hooks
  • api/getters.ts has JSDoc noting "Authorization hooks are NOT called"
  • api/mutations.ts (if present) has JSDoc noting "Authorization hooks are NOT called"
  • api/index.ts re-exports all public backend surface (getters, mutations, types, router type)
  • api/query-key-defs.ts defines shared key shapes imported by both query-keys.ts and prefetchForRoute
  • api/serializers.ts converts Date fields to ISO strings before setQueryData
  • Every loader catch block calls isConnectionError and logs a build-time warning
  • All page components are wrapped with ComposedRoute
  • Loading fallbacks are provided unconditionally (not guarded by typeof window)
  • useSuspenseQuery hooks 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.jsonexports and typesVersions added for each entry
  • CSS exported in package.json if the plugin ships UI components (postbuild.cjs auto-discovers CSS files — no changes needed there)
  • pnpm build passes with no errors

Type checking and linting

  • pnpm typecheck passes
  • pnpm lint passes

CLI and codegen project

  • packages/cli/src/utils/constants.tsPLUGINS array updated with new plugin entry
  • scripts/codegen/files/nextjs/lib/stack.ts — backend plugin registered
  • scripts/codegen/files/nextjs/lib/stack-client.tsx — client plugin registered
  • scripts/codegen/files/nextjs/app/pages/layout.tsx — StackProvider overrides added
  • Codegen project rebuilt and E2E passes: bash scripts/codegen/setup-nextjs.sh && pnpm -F e2e codegen:e2e:nextjs

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 test passes (unit tests)
  • cd e2e && pnpm codegen:e2e:nextjs -- tests/smoke.your-plugin.spec.ts passes

Documentation

  • docs/content/docs/plugins/your-plugin.mdx created
  • All exported types and interfaces have JSDoc comments
  • cd docs && pnpm dev renders without errors

Reference implementations

Complexity Plugin Source
Simple (CRUD) Todo plugin scripts/codegen/files/nextjs/lib/plugins/todo/
Full-featured Blog plugin packages/stack/src/plugins/blog/