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.appAnnouncementshas 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 throughpgSchema(name).tablerather thanpgTableso 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:
defineChaiServerPlugin(fn, { name })stamps the stable id. Use it always, even for a plugin that contributes one key....config.actionscomes 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.- Every array is spread, never replaced.
schemaFragments,requestMiddlewaresandsetupHookscollect contributions from every plugin. Assigning instead of appending silently drops whatever ran before you. - 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:deleteby name and neverredirects:*, so a future key is not granted retroactively. defaultRoleGrantsapplies 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.
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 |
Sidebar panels
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.actionsafter your own entries so the app and later plugins can override you. - Never replace an array you did not create.
schemaFragments,requestMiddlewaresandsetupHooksaccumulate. 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 |
Related
- Architecture Overview - where plugins sit in the three layers
- Configuration - the four config files and the
pluginskey - API - SDK entry points, the server instance, and the action registry
- Custom Blocks - the other half of the registry surface
- Roles and Permissions - how permission keys are granted and checked
- Caching and Revalidation - what the
tagson an action response do - Database Setup - dialects and the migration pipeline your fragment feeds

