Building plugins
Build your own server and client plugins — endpoints, schema, hooks, request scope, realtime events, and more.
A plugin extends createCMS without forking it. There are two halves, each a plain object you can build incrementally:
- a server plugin (
CMSPlugin) — added tocreateCMS({ plugins })— contributes endpoints, database tables, hooks, request-scoped query conditions, request interception, error codes, pruning, and more; - a client plugin (
CMSClientPlugin) — added tocreateCMSClient({ plugins })— contributes typed actions to the client.
Everything below is optional: a plugin uses only the seams it needs. Write the object with satisfies CMSPlugin so TypeScript checks the shape while keeping your literal types for inference.
A minimal plugin
The only required field is id — it namespaces the plugin's endpoints and error codes. A bare id is already a valid (if inert) plugin:
import type { CMSPlugin } from '@createcms/core';
export function pageViews() {
return { id: 'pageViews' } satisfies CMSPlugin;
}Register it on the server:
import { createCMS } from '@createcms/core';
import { pageViews } from './plugins/page-views';
export const cms = createCMS({
db,
collections,
media,
plugins: [pageViews()],
});The rest of this guide grows pageViews into a real plugin that counts views per root, then surveys the advanced seams using the official plugins as references.
Add a database table
definePluginSchema declares tables (and enums) that merge into your generated schema. Each table's columns is a map of column keys to a ColumnDefinition (see packages/cms/src/core/db/types.ts); only type is required.
| Field | Type | Description |
|---|---|---|
type | scalar or { enum } | Required. A scalar — 'text' | 'boolean' | 'integer' | 'timestamp' | 'jsonb' | 'tsvector' — or { enum: 'enumName' } referencing an enum declared in the schema's enums. |
columnName | string | SQL column name. Defaults to the snake_case of the column key. |
notNull | boolean | Add a NOT NULL constraint. |
primaryKey | boolean | Make this the primary key. |
unique | boolean | Add a unique constraint. |
default | DefaultValue | A default value (see below). |
defaultId | boolean | Generate a prefixed id at insert time. Requires defaultIdPrefix. |
defaultIdPrefix | string | Prefix for the id generated by defaultId. |
defaultNow | boolean | Default to now() (for timestamp columns). |
jsonType | string | TypeScript type applied to a jsonb column via Drizzle's .$type<…>(). |
references | object | A foreign key (see below). |
default is a discriminated union on kind:
| Kind | Shape | Meaning |
|---|---|---|
'literal' | { kind: 'literal', value } | A literal boolean, number, string, string[], or Record<string, unknown>. |
'sql' | { kind: 'sql', value: string } | A raw SQL expression. |
references is { table, column, onDelete?, onUpdate? }, where each action is one of 'cascade' \| 'restrict' \| 'no action' \| 'set null' \| 'set default'.
Indexes are table-level, not per-column. Declare them in TableDefinition.indexes — a map of index keys to { columns, unique?, using?: 'btree' \| 'gin', where? }:
import { definePluginSchema } from '@createcms/core';
const schema = definePluginSchema()({
enums: {
abTestStatus: { values: ['draft', 'running', 'paused', 'completed'] },
},
tables: {
abTests: {
tableName: 'ab_tests',
indexPrefix: 'abt',
columns: {
id: { type: 'text', primaryKey: true, defaultId: true, defaultIdPrefix: 'abTest' },
rootId: {
type: 'text',
notNull: true,
references: { table: 'roots', column: 'id', onDelete: 'cascade' },
},
status: {
type: { enum: 'abTestStatus' },
notNull: true,
default: { kind: 'literal', value: 'draft' },
},
createdAt: { type: 'timestamp', notNull: true, defaultNow: true },
},
// Indexes are table-level, keyed by name.
indexes: {
rootIdx: { columns: ['rootId'] },
statusIdx: { columns: ['status'] },
},
},
},
});Here is a second, minimal table (one row per root) built the same way:
import { definePlugin, definePluginSchema } from '@createcms/core';
// Curried: call `definePluginSchema()` (no type arg when you only declare your
// own tables), then pass the schema. Use `definePluginSchema<CoreTables>()` when
// you `extend` core tables so the extend keys are type-checked.
const schema = definePluginSchema()({
tables: {
pageViews: {
tableName: 'page_views',
indexPrefix: 'pgv',
columns: {
// one row per root → rootId is the primary key
rootId: {
type: 'text',
primaryKey: true,
references: { table: 'roots', column: 'id', onDelete: 'cascade' },
},
// `default` is a discriminated union, not a raw scalar.
count: {
type: 'integer',
notNull: true,
default: { kind: 'literal', value: 0 },
},
updatedAt: { type: 'timestamp', notNull: true, defaultNow: true },
},
},
},
});
// `definePlugin` keeps `id` a literal (so `cms.api.pageViews` is a real
// namespace, not a string index signature) and preserves endpoint contributions.
export function pageViews() {
return definePlugin({ id: 'pageViews', schema });
}Schema is build-time, not runtime: after adding or changing a plugin's schema, re-run createcms generate and apply the migration. The plugin's tables then exist in your database.
Add endpoints
endpoints is a record of createCMSEndpoint definitions. They surface on the server at cms.api.<id>.<method> and on a typed client at client.<id>.<method>.
The path must be /<id>/<method> — that's the convention the client proxy builds its request URLs from. Each handler reads db (and scope, userId) from reqCtx.context, and queries through the injected db:
import { createCMSEndpoint, cmsMeta } from '@createcms/core';
import { sql } from 'drizzle-orm';
import * as z from 'zod';
const endpoints = {
recordView: createCMSEndpoint(
'/pageViews/recordView',
{
method: 'POST',
body: z.object({ rootId: z.string() }),
metadata: cmsMeta(
{ $Infer: { body: {} as { rootId: string } } },
{ operation: 'update', scope: 'system', permissionResource: 'pageViews' },
),
},
async (reqCtx) => {
const { db } = reqCtx.context;
const { rootId } = reqCtx.body;
await db.execute(sql`
insert into "cms"."page_views" ("root_id", "count") values (${rootId}, 1)
on conflict ("root_id") do update set
"count" = "cms"."page_views"."count" + 1,
"updated_at" = now()
`);
return {};
},
),
getViews: createCMSEndpoint(
'/pageViews/getViews',
{
method: 'GET',
query: z.object({ rootId: z.string() }),
metadata: cmsMeta(
{ $Infer: { query: {} as { rootId: string } } },
{ operation: 'read', scope: 'system', permissionResource: 'pageViews' },
),
},
async (reqCtx) => {
const { db } = reqCtx.context;
const rows = await db.execute<{ count: number }>(sql`
select "count" from "cms"."page_views" where "root_id" = ${reqCtx.query.rootId}
`);
return { count: rows[0]?.count ?? 0 };
},
),
};Attach endpoints to the plugin object. The metadata (via cmsMeta) is required: operation and scope drive the auth/permission chain, permissionResource is what your authMiddleware checks, and $Infer is what makes the input typed on the client.
Every endpoint runs through your authMiddleware, the scope resolver, and hooks automatically. To opt an endpoint out (e.g. a public ingest), pass { public: true, operation, scope } to cmsMeta — it then handles its own access control.
Typed error codes
$ERROR_CODES declares the errors your plugin throws. On a server plugin they merge into cms.$ERROR_CODES (the server instance) — not the client's — so server-side consumers can branch on a stable code. Throw with better-call's APIError:
import { APIError } from 'better-call';
const $ERROR_CODES = {
PAGE_VIEWS_ROOT_NOT_FOUND: { status: 404, message: 'Root not found' },
} as const;
// inside a handler:
throw new APIError(404, {
code: 'PAGE_VIEWS_ROOT_NOT_FOUND',
message: $ERROR_CODES.PAGE_VIEWS_ROOT_NOT_FOUND.message,
});Prefix plugin codes with the plugin name (PAGE_VIEWS_ROOT_NOT_FOUND, not ROOT_NOT_FOUND) so they don't collide with a core code or another plugin's.
React to actions with hooks
hooks run before or after any CMS action (use the action name, or '*' for all). A before hook can override the input; an after hook sees the result. The official A/B plugin uses a before hook on publishBranch as an integrity guard:
hooks: {
after: [
{
action: 'archiveRoot',
handler: async (ctx) => {
// ctx.action, ctx.collection, ctx.db, ctx.input, ctx.scope
await ctx.db.execute(
sql`delete from "cms"."page_views" where "root_id" = ${ctx.input.rootId}`,
);
},
},
],
}The same before/after shape is available inline on createCMS({ hooks }) when you don't need a whole plugin.
Scope every request (multi-tenancy, i18n)
This is the most powerful seam. A plugin's init(ctx) can return scope conditions — per-table WHERE clauses and insert columns that the CMS applies to every core read and write automatically. The official multiTenant plugin scopes every table by tenant_slug:
import type { CMSPlugin, ScopeConditionFactory } from '@createcms/core';
import { sql } from 'drizzle-orm';
init(_ctx) {
// Runs once per request; `mw` is your authMiddleware's result.
const factory: ScopeConditionFactory = (mw) => {
const tenantSlug = (mw as { tenantSlug?: string }).tenantSlug;
return {
roots: {
where: sql`"cms"."roots"."tenant_slug" = ${tenantSlug}`,
insertColumns: { tenant_slug: tenantSlug },
},
// …assets, redirects, templates, variables, etc.
};
};
return { context: { scopeConditions: [factory] } };
}scopeConditions is a concatenated array, so multiple plugins (e.g. multi-tenant + i18n) compose: every factory's WHERE is AND-ed together. The init return's context is merged onto the request context; array fields like scopeConditions are appended rather than replaced.
Per-collection endpoints
Where endpoints mounts under the plugin id, collectionEndpoints(collection, ctx) is called once per collection and mounts under each one, at cms.api.<collection>.<method>. The official i18n plugin uses it for createTranslation / listTranslations:
collectionEndpoints: (collection, ctx) => ({
createTranslation: createCMSEndpoint(/* … */),
listTranslations: createCMSEndpoint(/* … */),
}),Intercept requests
onRequest(request, ctx) runs before routing — return { response } to short-circuit, { request } to rewrite, or nothing to continue. onResponse(response, ctx) post-processes. The A/B plugin uses onRequest to rate-limit its public ingest before any DB work:
async onRequest(request, ctx) {
const url = new URL(request.url);
// The A/B plugin caps its one anonymous write path, POST /abTest/trackEvent.
if (request.method === 'POST' && url.pathname.endsWith('/abTest/trackEvent')) {
const limited = await rateLimit(request);
if (limited) return { response: limited }; // 429, pipeline stops here
}
}Path-bound middleware
For middleware tied to a path rather than an action, use middlewares:
middlewares: [{ path: '/pageViews/*', middleware: myMiddleware }],Notifications, realtime events, and pruning
Four more seams, each optional:
onNotification(notification)— called whenever the CMS emits a notification (the same handler is also available as the top-levelcreateCMS({ onNotification })option). Use it to mirror notifications to email, Slack, etc.notificationTypes— a Zod map keyed by a notificationtype, whose values schema that type'smeta. Two effects: the keys are folded into the corenotification_typeenum atcreatecms generate(so the plugin can persist its owntype— re-generate after adding it), and the map is inferred intotypeof cmssocreateNotificationRoutertypes eachmetaper type. Raise one withctx.notificationService.notify({ type, meta, … }).realtimeEvents— a Zod map of events your plugin publishes over the shared realtime transport (the transport itself is event-agnostic). Use the schemas to validate on publish and to parse on the client; the CMS does not currently thread them into a typedcms.realtime.publishor typed client subscriptions. (Requiresrealtimeconfigured.)pruning: { plan, execute }— contribute torunPruningPass:planreturns the deletable rows for a root,executedeletes them in the retention transaction. Use it so your plugin's tables honourdataRetention.
import * as z from 'zod';
// on the plugin object:
notificationTypes: {
abTestWinner: z.object({ testId: z.string(), variant: z.string() }),
},After createcms generate, abTestWinner is a valid notification_type, and createNotificationRouter<typeof cms> types its meta per type, so the app routes it with full autocomplete.
The client plugin
A client plugin adds actions to createCMSClient. getActions($fetch, $store, baseURL) returns a record merged onto the client; $ERROR_CODES adds the plugin's error codes to client.$ERROR_CODES:
import type { CMSClientPlugin } from '@createcms/core';
export function pageViewsClient(): CMSClientPlugin {
return {
id: 'pageViews',
getActions: ($fetch) => ({
recordView: (rootId: string) =>
$fetch('/pageViews/recordView', { method: 'POST', body: { rootId } }),
}),
$ERROR_CODES: { PAGE_VIEWS_ROOT_NOT_FOUND: { status: 404, message: 'Root not found' } },
};
}import { createCMSClient } from '@createcms/core/react';
import { pageViewsClient } from './plugins/page-views-client';
export const client = createCMSClient<typeof cms>()({
baseURL: '/api/cms',
plugins: [pageViewsClient()],
});
// fully typed:
await client.pageViews.getViews({ query: { rootId } }); // from the server endpoint
client.recordView(rootId); // from getActionsHow the types flow
Plugin contributions reach typeof cms and the client through inference — there's no codegen step:
- server
endpoints→cms.api.<id>.*and (viatypeof cms)client.<id>.*; collectionEndpoints→cms.api.<collection>.*;- client
getActions→client.*; realtimeEvents→ the realtime event registry your plugin publishes;- server
$ERROR_CODES→cms.$ERROR_CODES; client$ERROR_CODES→client.$ERROR_CODES.
Keep satisfies CMSPlugin on the server object and type the client plugin as CMSClientPlugin, and a configured createCMS / createCMSClient infers the rest.
Reference: plugin fields
Every field is optional except id.
Server (CMSPlugin) | What it does |
|---|---|
id | Required. Namespaces endpoints + error codes. |
endpoints | Routes at cms.api.<id>.<method> (path /<id>/<method>). |
collectionEndpoints | Routes mounted on every collection. |
schema | Tables + enums merged into the generated schema. |
hooks | before / after handlers on CMS actions. |
middlewares | Path-bound better-call middleware. |
init | Per-instance setup; returns context (incl. scopeConditions) + extra hooks. |
onRequest / onResponse | Intercept / rewrite requests and responses. |
onNotification | Handle every emitted notification. |
notificationTypes | Zod meta map; adds notification types (enum + typed router). |
realtimeEvents | Zod event map published over the realtime transport. |
pruning | plan / execute for data-retention. |
$ERROR_CODES | Typed error codes. |
Client (CMSClientPlugin) | What it does |
|---|---|
id | Required. |
getActions | Actions merged onto the client. |
init | Client-side setup ($fetch, $store). |
pathMethods | HTTP method per action path. |
atomListeners | CMSAtomListener[] — invalidate query atoms after a mutation. Each { matcher, signal, callback? } toggles a signal atom so subscribed createCMSQuery atoms refetch. |
$ERROR_CODES | Typed error codes on the client. |