ChaiBuilder Logo

Developing Plugins

A plugin is how a whole feature enters ChaiBuilder. Every Pro feature you already use is one: redirects, trash, revisions, the block library, stock image search, multilingual, animations. There is no feature flag matrix anywhere in the product. Registering a plugin turns its feature on, and the plugin's options carry its configuration.

That same mechanism is open to you. A plugin you write is registered exactly like a built-in one, gets the same contribution points, and is subject to the same rules.

Read Architecture Overview first if you want the mental model rather than the API.

When a plugin is the right tool

A plugin is the heaviest extension point ChaiBuilder has. Most extensions are smaller than that, so start here:

You want to Use
Add a new kind of section an editor can drop on a page Custom Blocks
Format a value inside a {{ }} binding Pipes
Feed a collection or a record into the builder Data Providers and Page Types
Expose one server endpoint to the builder A single action in actions on chaibuilder.config.ts
Add a feature: its own data, its own permissions, its own UI, its own on/off switch A plugin

The line is ownership. If the thing you are adding owns a table, a permission key, a panel, or an entry in the request lifecycle, it is a plugin. If it is one function or one component, it is not.

The two halves

Almost every feature is two halves, registered in two different places:

Server plugin Client plugin
What it is (config) => config, a reducer over the server config { name, register() }
What it contributes Actions, permissions, database tables, feature flags, request middleware, lifecycle hooks Sidebar panels, slots, media manager tabs, add-block tabs, settings fields, flags
Where it is registered plugins in chaibuilder.config.ts plugins prop on <ChaiWebsiteBuilder />
Runs On the server, at config build time In the browser, once before the editor mounts

Keep the two lists mirrored. A server plugin with no client plugin is a feature with no UI, and a client plugin with no server plugin is UI whose every request the server will reject. Some plugins legitimately have one half only: formSubmissions is server only (the host's own form handler calls it), pageErrors and realtime are client only.

Anatomy

The convention is one folder per plugin, split by the boundary it has to respect:

src/plugins/announcements/
├── permissions.ts            # permission keys - imported by both halves
├── schema/
│   ├── pg.ts                 # drizzle table, Postgres
│   └── sqlite.ts             # the same table, SQLite
├── server/
│   ├── index.ts              # the plugin function - the only server entry point
│   ├── get-announcements.ts  # one file per action
│   ├── create-announcement.ts
│   └── delete-announcement.ts
└── client/
    ├── index.ts              # the client plugin - the only client entry point
    ├── announcements-panel.tsx
    └── use-announcements.ts

permissions.ts sits above both folders because it is isomorphic: the server enforces the keys and the client hides buttons with them. Nothing else crosses. server/ must never be reachable from a browser bundle, and client/ must never be imported by the server plugin.

Build a plugin end to end

The worked example is an Announcements plugin: a site-wide announcement bar that editors manage from a sidebar panel and the front end renders above the page.

1. Pick the names first

Names are permanent in the same way block type strings are. Four of them, fixed before you write anything:

Thing Convention Example
Plugin id <vendor>:<feature>, lowercase app:announcements
Table app_<feature>, snake_case app_announcements
Permission keys <entity>:<operation> announcements:read
Action names SCREAMING_SNAKE_CASE, verb first GET_ANNOUNCEMENTS

Built-in plugins use the chai: vendor prefix. Use your own so a future Pro plugin can never collide with yours.

2. Declare the table, once per dialect

A plugin ships schema, not migrations. Contribute a drizzle fragment for each dialect your deployments run, and ChaiBuilder merges the fragment for the active dialect into db.schema before the database is registered.

// src/plugins/announcements/schema/pg.ts
import { boolean, index, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core'

export const appAnnouncements = pgTable(
  'app_announcements',
  {
    id: uuid().defaultRandom().primaryKey().notNull(),
    createdAt: timestamp({ withTimezone: true, mode: 'string' }).defaultNow().notNull(),
    updatedAt: timestamp({ withTimezone: true, mode: 'string' }).defaultNow().notNull(),
    app: uuid().notNull(),
    message: text().notNull(),
    linkUrl: text(),
    active: boolean().default(true).notNull(),
    createdBy: text(),
  },
  (table) => [index('idx_app_announcements_app_active').on(table.app, table.active)],
)
// src/plugins/announcements/schema/sqlite.ts
import { sql } from 'drizzle-orm'
import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'

export const appAnnouncements = sqliteTable(
  'app_announcements',
  {
    id: text()
      .primaryKey()
      .$defaultFn(() => crypto.randomUUID())
      .notNull(),
    createdAt: text().default(sql`(datetime('now'))`).notNull(),
    updatedAt: text().default(sql`(datetime('now'))`).notNull(),
    app: text().notNull(),
    message: text().notNull(),
    linkUrl: text(),
    active: integer({ mode: 'boolean' }).default(true).notNull(),
    createdBy: text(),
  },
  (table) => [index('idx_app_announcements_app_active').on(table.app, table.active)],
)

Three rules that are easy to get wrong:

  • Both dialects export the same names. The fragment is keyed by dialect and only the matching one is merged, so schema.appAnnouncements has to resolve either way.
  • Every row carries app. ChaiBuilder is multi-site by design and every query is scoped by app id. A table without that column leaks content across sites.
  • If your Postgres deployment parks ChaiBuilder under a named schema via CHAIBUILDER_POSTGRESS_SCHEMA, declare your table through pgSchema(name).table rather than pgTable so it lands in the same schema as everything else.

3. Declare the permission keys

// src/plugins/announcements/permissions.ts
export const ANNOUNCEMENTS_PERMISSIONS = {
  'announcements:read': 'announcements:read',
  'announcements:write': 'announcements:write',
} as const

export type AnnouncementsPermission = keyof typeof ANNOUNCEMENTS_PERMISSIONS

export const ANNOUNCEMENTS_PERMISSION_LIST: string[] = Object.keys(ANNOUNCEMENTS_PERMISSIONS)

The file imports nothing, which is what makes it safe for the panel component to import as well.

4. Write the actions

An action is a class with a validation schema, an execute, and optionally a required permission. ChaiBaseAction validates the payload against your zod schema before execute ever runs, so execute can assume its input is well formed.

// src/plugins/announcements/server/get-announcements.ts
import { ActionError, ChaiBaseAction, db, safeQuery, schema } from 'chaipro/nextjs/server'
import { desc, eq } from 'drizzle-orm'
import { z } from 'zod'

export type Announcement = {
  id: string
  message: string
  linkUrl: string | null
  active: boolean
}

export class GetAnnouncementsAction extends ChaiBaseAction<
  Record<string, never>,
  { announcements: Announcement[] }
> {
  protected getValidationSchema() {
    return z.object({})
  }

  async execute(): Promise<{ announcements: Announcement[] }> {
    const appId = this.context!.appId!

    const { data, error } = await safeQuery(() =>
      db
        .select({
          id: schema.appAnnouncements.id,
          message: schema.appAnnouncements.message,
          linkUrl: schema.appAnnouncements.linkUrl,
          active: schema.appAnnouncements.active,
        })
        .from(schema.appAnnouncements)
        .where(eq(schema.appAnnouncements.app, appId))
        .orderBy(desc(schema.appAnnouncements.createdAt)),
    )

    if (error) {
      throw new ActionError('Error fetching announcements', 'ERROR_FETCHING_ANNOUNCEMENTS', 500, error)
    }

    return { announcements: data ?? [] }
  }
}

A mutation looks the same, plus the cache tags it wants revalidated:

// src/plugins/announcements/server/create-announcement.ts
import { ActionError, ChaiBaseAction, db, safeQuery, schema } from 'chaipro/nextjs/server'
import { z } from 'zod'
import type { Announcement } from './get-announcements'

export type CreateAnnouncementActionData = {
  message: string
  linkUrl?: string
}

export const announcementsTag = (appId: string) => `announcements:${appId}`

export class CreateAnnouncementAction extends ChaiBaseAction<
  CreateAnnouncementActionData,
  { announcement: Announcement; tags: string[] }
> {
  protected getValidationSchema() {
    return z.object({
      message: z.string().min(1).max(200),
      linkUrl: z.string().url().optional(),
    })
  }

  async execute(data: CreateAnnouncementActionData) {
    const appId = this.context!.appId!

    const { data: rows, error } = await safeQuery(() =>
      db
        .insert(schema.appAnnouncements)
        .values({
          app: appId,
          message: data.message,
          linkUrl: data.linkUrl ?? null,
          createdBy: this.context?.userId,
        })
        .returning({
          id: schema.appAnnouncements.id,
          message: schema.appAnnouncements.message,
          linkUrl: schema.appAnnouncements.linkUrl,
          active: schema.appAnnouncements.active,
        }),
    )

    if (error || !rows?.[0]) {
      throw new ActionError('Error creating announcement', 'ERROR_CREATING_ANNOUNCEMENT', 500, error)
    }

    return { announcement: rows[0], tags: [announcementsTag(appId)] }
  }
}

What the framework does with each piece:

Piece Effect
getValidationSchema() Runs before execute. A failure returns a VALIDATION_ERROR without touching your code
this.context.appId The resolved site. Always scope queries by it
this.context.userId The acting user, for audit columns
this.context.userAccess Role and effective permissions, already clamped for delegated credentials
ActionError(message, code, status, cause) The only error shape that reaches the client intact. Anything else becomes a generic ACTION_ERROR
tags on the response Collected by the HTTP handler and passed to cache revalidation. See Caching and Revalidation

Actions are authenticated by default. There is no way to make a registered action public from outside the SDK, which is the right default.

5. Write the server plugin

The plugin itself is one function. It receives the config built so far and returns the next one.

// src/plugins/announcements/server/index.ts
import { defineChaiServerPlugin } from 'chaipro/nextjs/server'
import type { ChaiServerPlugin } from 'chaipro/nextjs/server'
import { ANNOUNCEMENTS_PERMISSIONS as P } from '../permissions'
import * as pgTables from '../schema/pg'
import * as sqliteTables from '../schema/sqlite'
import { CreateAnnouncementAction } from './create-announcement'
import { DeleteAnnouncementAction } from './delete-announcement'
import { GetAnnouncementsAction } from './get-announcements'

export type AnnouncementsPluginOptions = {
  /** Registering the plugin enables the feature; pass false to register it off. */
  enabled?: boolean
}

/** Assigns the permission an action requires and returns the same instance. */
const wp = <T extends { requiredPermission?: string }>(action: T, permission: string): T => {
  action.requiredPermission = permission
  return action
}

export const announcementsPlugin = (options?: AnnouncementsPluginOptions): ChaiServerPlugin =>
  defineChaiServerPlugin(
    (config) => ({
      ...config,
      features: {
        ...config.features,
        announcements: options?.enabled ?? true,
      } as typeof config.features,
      actions: {
        GET_ANNOUNCEMENTS: wp(new GetAnnouncementsAction(), P['announcements:read']),
        CREATE_ANNOUNCEMENT: wp(new CreateAnnouncementAction(), P['announcements:write']),
        DELETE_ANNOUNCEMENT: wp(new DeleteAnnouncementAction(), P['announcements:write']),
        // Spread last: anything the app registered under the same name wins.
        ...config.actions,
      },
      defaultRoleGrants: {
        ...config.defaultRoleGrants,
        editor: [
          ...(config.defaultRoleGrants?.editor ?? []),
          P['announcements:read'],
          P['announcements:write'],
        ],
        viewer: [...(config.defaultRoleGrants?.viewer ?? []), P['announcements:read']],
      },
      schemaFragments: [
        ...(config.schemaFragments ?? []),
        { pg: { ...pgTables }, sqlite: { ...sqliteTables } },
      ],
    }),
    { name: 'app:announcements' },
  )

Four things are load bearing here:

  1. defineChaiServerPlugin(fn, { name }) stamps the stable id. Use it always, even for a plugin that contributes one key.
  2. ...config.actions comes last. Plugins run in array order, each seeing the previous one's output, and the last writer wins. Spreading the incoming actions after yours means a later plugin or the app itself can override your action by name, never the reverse.
  3. Every array is spread, never replaced. schemaFragments, requestMiddlewares and setupHooks collect contributions from every plugin. Assigning instead of appending silently drops whatever ran before you.
  4. Options carry the config, and the plugin sets its flag unconditionally. Registering the plugin is the switch. announcementsPlugin({ enabled: false }) registers the types and the actions while leaving the feature off, which is how a feature gets parked without removing it from the build.

6. Write the client plugin

A client plugin is a name and a register() that makes the registry calls. Registration is once per name, so React strict mode and repeated builder mounts cannot double register it, and a throwing register() is caught and logged rather than taking the editor down.

// src/plugins/announcements/client/index.tsx
'use client'

import { Megaphone } from 'lucide-react'
import { lazy } from 'react'
import { Button } from 'chaipro/ui/button'
import {
  registerChaiSidebarPanel,
  useUserPermissions,
  type ChaiClientPlugin,
} from 'chaipro'
import { ANNOUNCEMENTS_PERMISSIONS } from '../permissions'

const AnnouncementsPanel = lazy(() =>
  import('./announcements-panel').then((m) => ({ default: m.AnnouncementsPanel })),
)

const AnnouncementsButton = ({ isActive, show }: { isActive: boolean; show: () => void }) => {
  const { data } = useUserPermissions()
  const permissions = data?.permissions ?? null

  // GET_ANNOUNCEMENTS would 403 without this, so the panel has nothing to show.
  if (permissions && !permissions.includes(ANNOUNCEMENTS_PERMISSIONS['announcements:read'])) {
    return null
  }

  return (
    <Button
      variant="ghost"
      size="icon"
      onClick={show}
      title="Announcements"
      className={`h-8 w-8 ${isActive ? 'bg-primary text-primary-foreground' : ''}`}>
      <Megaphone className="h-5 w-5" />
    </Button>
  )
}

export const announcementsClientPlugin: ChaiClientPlugin = {
  name: 'app:announcements',
  register: () => {
    registerChaiSidebarPanel('announcements', {
      button: AnnouncementsButton,
      label: 'Announcements',
      position: 'bottom',
      view: 'modal',
      width: 700,
      order: 730,
      panel: AnnouncementsPanel,
    })
  },
}

The panel talks to your actions over the same endpoint the rest of the builder uses:

// src/plugins/announcements/client/use-announcements.ts
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { useApiUrl, useFetch } from 'chaipro'

export const useAnnouncements = () => {
  const apiUrl = useApiUrl()
  const fetchAPI = useFetch()

  return useQuery({
    queryKey: ['GET_ANNOUNCEMENTS'],
    queryFn: async () => fetchAPI(apiUrl, { action: 'GET_ANNOUNCEMENTS', data: {} }),
    refetchOnWindowFocus: false,
  })
}

export const useCreateAnnouncement = () => {
  const apiUrl = useApiUrl()
  const fetchAPI = useFetch()
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: async (data: { message: string; linkUrl?: string }) =>
      fetchAPI(apiUrl, { action: 'CREATE_ANNOUNCEMENT', data }),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['GET_ANNOUNCEMENTS'] })
      toast.success('Announcement added')
    },
    // The server rejects over-long messages and bad URLs by message; surface it as-is.
    onError: (error: any) => toast.error(error?.message || 'Something went wrong'),
  })
}

useApiUrl and useFetch resolve the builder's action endpoint and attach the access token, so you never hand-roll auth headers. The action name in the body is the key you registered on the server.

7. Register both halves

Server, in the single list the app keeps for this:

// src/chaibuilder.plugins.ts
import { redirectsPlugin } from 'chaipro/plugins/redirects/server'
import { trashPlugin } from 'chaipro/plugins/trash/server'
import type { ChaiServerPlugin } from 'chaipro/nextjs/server'
import { announcementsPlugin } from '@/plugins/announcements/server'

export const chaiServerPlugins: ChaiServerPlugin[] = [
  redirectsPlugin(),
  trashPlugin(),
  announcementsPlugin(),
]

That list is consumed by buildChaiBuilderConfig:

// chaibuilder.config.ts
import { buildChaiBuilderConfig } from 'chaipro/nextjs/server'
import { chaiServerPlugins } from './src/chaibuilder.plugins'

export default buildChaiBuilderConfig({
  db: /* ... */,
  plugins: chaiServerPlugins,
})

Client, on the builder component:

// src/app/(builder)/admin/editor/editor.tsx
'use client'

import { ChaiWebsiteBuilder } from 'chaipro/payload/builder'
import { redirectsClientPlugin } from 'chaipro/plugins/redirects/client'
import { trashClientPlugin } from 'chaipro/plugins/trash/client'
import { announcementsClientPlugin } from '@/plugins/announcements/client'

// Mirrors the server list in src/chaibuilder.plugins.ts. One import per plugin
// so an unused feature UI cannot reach the editor chunk.
const chaiClientPlugins = [redirectsClientPlugin, trashClientPlugin, announcementsClientPlugin]

export default function Editor(props) {
  return <ChaiWebsiteBuilder {...props} plugins={chaiClientPlugins} />
}

Import each client plugin from its own subpath rather than the chaipro/plugins/client barrel. The barrel tree-shakes, but per-plugin imports are the guarantee: nothing you did not name can reach the chunk.

8. Generate the migration

The SDK ships schema; migrations are owned by your app. Your fragment is already in the merged drizzle schema, so the normal flow picks it up.

On Payload, chaiBuilderSchemaHook merges the ChaiBuilder tables into Payload's drizzle schema before the snapshot, so one pipeline covers both:

pnpm payload migrate:create
pnpm payload migrate

On a plain drizzle setup, point drizzle.config.ts at the schema barrel and run:

pnpm drizzle-kit generate
pnpm drizzle-kit migrate

Review the emitted SQL before running it. migrate:create emits schema diffs only, so any data backfill your feature needs is a migration you write by hand.

Built-in plugins differ here. The full set of Pro plugin tables is injected whether or not the plugin is registered, so turning a built-in feature on later needs no migration. Your own plugin's table only exists in the schema while your plugin is in the list, so add the plugin first, then generate.

9. Read it from the front end

The panel writes; the site reads. Anything registered on the server config is available to your rendering code through the ChaiBuilder instance and the shared drizzle handle:

// src/app/(frontend)/announcement-bar.tsx
import { db, safeQuery, schema } from 'chaipro/nextjs/server'
import { and, eq } from 'drizzle-orm'

export async function AnnouncementBar({ appId }: { appId: string }) {
  const { data } = await safeQuery(() =>
    db
      .select({ message: schema.appAnnouncements.message, linkUrl: schema.appAnnouncements.linkUrl })
      .from(schema.appAnnouncements)
      .where(and(eq(schema.appAnnouncements.app, appId), eq(schema.appAnnouncements.active, true)))
      .limit(1),
  )

  const announcement = data?.[0]
  if (!announcement) return null

  return (
    <div className="bg-primary px-4 py-2 text-center text-sm text-primary-foreground">
      {announcement.linkUrl ? <a href={announcement.linkUrl}>{announcement.message}</a> : announcement.message}
    </div>
  )
}

Wrap the read in the framework cache and tag it with the same announcements:<appId> tag your mutation actions return, and publishing an announcement invalidates the bar without a redeploy.

Server contribution reference

Everything a server plugin can contribute, in the order you are likely to need it.

Config key What it does Merge rule
actions Registers named actions reachable from the builder and from dispatchChaiAction Object spread. Spread config.actions last
features Feature flags. The whole resolved object is sent to the builder Object spread. Your value wins over what came before
schemaFragments Drizzle tables and relations, per dialect Append
requestMiddlewares Runs while resolving a public page request Append, keyed by name
setupHooks Runs once at the end of buildChaiBuilderConfig with the resolved config Append
defaultRoleGrants Extra permission keys for the built-in default roles Per-role union
trash Entities that soft-delete instead of hard-delete Merged by entity key
pageTypes, chaiCollections, blockDataProviders Content sources, same shape as the app config Append or spread
contextResolver Process-wide request context resolution Last writer wins
mediaManager Media manager tabs and upload rules Object spread
onPageNotFound, resolveDynamicTemplateTie Routing arbitration Last writer wins

Feature flags

The resolved features object is serialized into the builder bootstrap payload, so a flag your plugin sets is readable in the editor. That is the contract that lets a panel hide itself when the server would reject its calls.

Plugin-owned keys are deliberately excluded from the app-facing features input type. A project cannot switch a plugin's feature on from chaibuilder.config.ts; it registers the plugin, or it does not. The built-in plugins declare their flag's type by augmenting ChaiPluginFeatures from a small side-effect-imported module, which is why features.redirects exists in the config type exactly when the redirects plugin is part of the program.

Permissions

The RBAC catalog is the union of the core keys and the keys the registered plugins declare. Built-in plugins call registerChaiPermissions(owner, keys) so wildcard expansion (announcements:*) knows the keys exist. Grants are checked at dispatch: the action's requiredPermission is resolved (statically, or as a function of the payload and context), and a user without it gets a 403 before execute runs.

Two conventions worth keeping:

  • Grant explicitly for anything that affects the live site. Redirects seed redirects:read, redirects:create, redirects:update, redirects:delete by name and never redirects:*, so a future key is not granted retroactively.
  • defaultRoleGrants applies only to the built-in default roles. A project running database-managed roles or an explicit role map owns its own grants, and your seeds are never overlaid onto them.

See Roles and Permissions.

Request middleware

Middleware runs while a public page request is being resolved, before 404 handling. Return a redirect target, or null to let resolution continue. This is exactly how stored redirects work:

requestMiddlewares: [
  ...(config.requestMiddlewares ?? []),
  {
    name: 'app:announcements',
    handler: async ({ slug, lang }) => {
      const target = await lookupCampaignRedirect(slug, lang)
      return target ? { redirect: target, permanent: false } : null
    },
  },
]

Entries are keyed by name, so re-registering under the same name replaces rather than duplicates. This runs on every unresolved public request, so cache the lookup.

Action hooks

Core actions announce lifecycle events and plugins subscribe, so the core never imports plugin code. Two hooks exist today:

Hook Fires when Payload
page:slug-changed Live page slugs move { appId, slugUpdates, userId }
page:path-claimed A page now owns a path (created or renamed onto it) { appId, slug }

Subscribe from a setup hook, so subscription happens once the config is resolved:

setupHooks: [
  ...(config.setupHooks ?? []),
  () => {
    registerChaiActionHook('page:path-claimed', 'app:announcements', async ({ appId, slug }) => {
      await dropAnnouncementsForPath(appId, slug)
      return [announcementsTag(appId)]
    })
  },
]

Handlers are keyed per hook by their subscriber id, so a re-run replaces rather than duplicates. Cache tags you return are aggregated and included in the firing action's revalidation. A throwing handler is logged and skipped: hook work must never block the action that fired it.

Instance APIs

Built-in plugins hang a server-side namespace off the ChaiBuilder instance with registerChaiInstanceApi, which is what makes cb.redirects.getRedirect(slug) and cb.formSubmissions.createFormSubmission(...) resolve only when the plugin is registered. Each registered function is bound to the request context, and the calls that dispatch actions carry the same permission and flag checks as the HTTP path.

This helper is currently internal to the SDK. From your own plugin, export the functions directly from server/index.ts and import them where you need them. You get the same behavior without the namespace sugar.

Client extension reference

Everything register() can call, imported from chaipro.

API Adds
registerChaiSidebarPanel(id, panel) An icon in the left rail plus its panel, modal, overlay or drawer
registerChaiSlot(slotId, Component) A component into a named place in the editor chrome
registerChaiMediaManagerTab(id, tab) A tab in the media manager, next to the site assets view
registerChaiAddBlockTab(id, tab) A tab in the Add Block panel
registerChaiHook(name, fn) A step in a pipeline, currently before:save:page and after:save:page
registerChaiFeatureFlag(key, options) A client-side toggle, read with useChaiFeatureFlag or <IfChaiFeatureFlag>
registerChaiBlockSettingField / Widget / Template Custom RJSF controls in the block settings form
registerChaiStructureCheckRule(rule) A rule in the page structure checker
registerChaiPreImportHTMLHook(fn) A transform applied to HTML before import
registerChaiSaveToLibrary(component) The save-to-library affordance
registerChaiLibrary(id, library) A block library source
registerChaiSidebarPanel('announcements', {
  button: AnnouncementsButton,   // rendered in the rail; receives { isActive, show, panelId, position }
  panel: AnnouncementsPanel,     // the body
  label: 'Announcements',
  position: 'bottom',            // 'top' or 'bottom'
  view: 'modal',                 // 'standard' | 'modal' | 'overlay' | 'drawer'
  width: 700,
  order: 730,
})

Panels sort by order ascending within their position group, with registration order breaking ties. Built-in panels leave gaps of 10 so you can slot in between them: AI 10, Add Blocks 20, Outline 30, Images 100, SEO 110, Page Errors 120, AI Credits 130, Help 700, Trash 710, Redirects 720, User Info 900, Logout 1000. A panel registered without an order lands at 500, after every ordered panel. The AI, Add Blocks, Outline and Logout panels are pinned and ignore whatever order you pass.

Return null from your button component to hide the panel entirely. That is the gate you use for permissions and for a feature that is off.

Slots

Slots are the finer-grained extension point. Register a component against a slot id and the editor renders it wherever that slot appears. Multiple plugins can fill the same slot; each is wrapped in an error boundary, and lazy components are allowed.

Slot id Where it renders
TOPBAR_LEFT, TOPBAR_CENTER, TOPBAR_RIGHT The editor top bar
PUBLISH_MENU_ITEMS The end of the publish dropdown
AFTER_PAGE_MORE_OPTIONS The page options menu
BEFORE_OUTLINE Above the outline tree
AFTER_BLOCK_OPTIONS, AFTER_BODY_BLOCK_OPTIONS The block floating actions
AFTER_BLOCK_ATTRIBUTES Below the block attributes accordion
SETTINGS_FIELD_ACTIONS Next to each field in block settings, with { field, blockType } in context
BLOCK_STYLING_ELEMENTS The styling panel element list
SEO_PANEL.TRIGGER, SEO_PANEL.CONTENT Extra tabs in the SEO panel
SEO_FIELD_ACTIONS Next to each SEO field
THEME_PANEL_ACTIONS The theme panel header
AI_PANEL_HEADER The AI panel header row
MEDIA_MANAGER, TOP_BAR Full component replacement
LANGUAGE_SWITCHER The language switcher, filled by the multilingual plugin
EMPTY_PAGE_STARTER_CONTENT The empty page starter dialog body
AFTER_BUILDER After the whole builder tree. Use it to mount dialogs and sheets that must outlive the menu that opened them

A slot component decides for itself whether it applies. The AI plugin registers one component into SETTINGS_FIELD_ACTIONS and returns null unless the field is alt on an Image block.

Gating the UI

Two gates, and a panel usually wants both:

const { data } = useUserPermissions()
if (!data?.permissions?.includes('announcements:read')) return null

or declaratively, with the component the SDK ships:

<PermissionChecker permission="announcements:read" fallback={null}>
  <AnnouncementsList />
</PermissionChecker>

Hiding UI is a courtesy, not a control. The server enforces the same key on dispatch, so a user who forges the request still gets a 403.

Rules that keep plugins composable

  • Order matters, and later wins. Plugins run in array order. Spread config.actions after your own entries so the app and later plugins can override you.
  • Never replace an array you did not create. schemaFragments, requestMiddlewares and setupHooks accumulate. Always spread the incoming value first.
  • Registration must be idempotent. Rebuilds, HMR and multiple route module graphs all re-run your reducer. Keyed registries (by plugin name, by middleware name, by hook subscriber id) replace rather than duplicate, which is why every one of them takes a name. Use a stable one.
  • Never import another plugin's internals. If two plugins need the same thing, it belongs in the app, not in a cross-import. The only thing that legitimately crosses is a permission constants file, because it imports nothing itself.
  • Keep the halves apart. server/ code must never be reachable from the editor bundle. Publish or import each half from its own entry point.
  • Check your own flag at every entry point. A feature that is registered but off must reject its actions, skip its middleware, and hide its panel. Rows already stored are left untouched, just unread, so the switch can be flipped back and forth.
  • Fail loudly at build, quietly at runtime. A bad registration should throw while the config is being built. A failing hook, middleware or slot component should be logged and skipped, never allowed to break the request or the editor.

Testing

Plugins are ordinary functions over a config object, which makes the core of them testable without a server:

import { resolveChaiBuilderConfig } from 'chaipro/nextjs/server'
import { announcementsPlugin } from '@/plugins/announcements/server'

it('registers its actions and turns the feature on', () => {
  const config = resolveChaiBuilderConfig({ db: stubDb(), plugins: [announcementsPlugin()] })

  expect(config.features.announcements).toBe(true)
  expect(config.actions.GET_ANNOUNCEMENTS).toBeDefined()
})

it('registers off when asked', () => {
  const config = resolveChaiBuilderConfig({
    db: stubDb(),
    plugins: [announcementsPlugin({ enabled: false })],
  })

  expect(config.features.announcements).toBe(false)
})

Worth covering: the feature absent when the plugin is not registered, every action rejecting while the flag is off, the permission attached to each action, and one integration test per action against a real database.

Use happy-dom for any test that renders a panel.

Public API for plugin authors

Everything the worked example uses is exported. The table is the full set you need.

Import from Names
chaipro/nextjs/server defineChaiServerPlugin, buildChaiBuilderConfig, resolveChaiBuilderConfig, ChaiBaseAction, ActionError, db, safeQuery, schema, getDb, dispatchChaiAction, getChaiBuilder, registerChaiActionHook, registerChaiRequestMiddleware, CHAI_PERMISSIONS, getPermissionCatalog, hasPermission
chaipro/nextjs/server (types) ChaiServerPlugin, ChaiSchemaFragment, ChaiSetupHook, ChaiRequestMiddleware, ChaiRequestMiddlewareArgs, ChaiRequestMiddlewareResult, ChaiBuilderServerConfigInput, ChaiActionContext, ChaiDefaultRoleGrants
chaipro ChaiClientPlugin, registerChaiSidebarPanel, registerChaiSlot, ChaiSlot, CHAI_SLOT_IDS, registerChaiMediaManagerTab, registerChaiAddBlockTab, registerChaiHook, registerChaiFeatureFlag, registerChaiBlockSettingField, registerChaiStructureCheckRule, useApiUrl, useFetch, useUserPermissions, PermissionChecker, useTranslation
chaipro/ui/* The shared UI primitives, so your panel matches the editor
chaipro/plugins/<name>/server and /client The built-in plugins

A handful of helpers the built-in plugins use are internal to the SDK today: setPluginFeatures, setPluginMediaManager, registerChaiPermissions, registerChaiInstanceApi, withActionPermission and mergeDefaultRoleGrants. Each is a few lines of object merging, and the worked example shows the equivalent inline. The observable behavior of your plugin is the same either way.

Built-in plugins worth reading

The best documentation for a plugin is a plugin. These are the ones to open first:

Plugin Subpath Demonstrates
redirectsPlugin plugins/redirects The complete surface: flag, actions, permissions, schema, request middleware, action hooks, instance API, sidebar panel
trashPlugin plugins/trash Trash entities, a permission derived from another feature's key, a table plus a panel
revisionsPlugin plugins/revisions Options that merge over a baseline, a publish-menu slot
mediaSearchPlugin plugins/media-search Provider options, two media manager tabs each gated on config
animationPlugin plugins/animation The minimum viable plugin: one feature flag and nothing else
formSubmissionsPlugin plugins/form-submissions A server-only plugin whose entire surface is a table plus an API
mediaPlugin plugins/media Host-provided implementations passed in as options

© ChaiBuilder. All rights reserved.