⚠️ Work in progress — createCMS is pre-1.0 and not production-ready (not tested in production). Expect breaking changes.
createCMS

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 to createCMS({ plugins }) — contributes endpoints, database tables, hooks, request-scoped query conditions, request interception, error codes, pruning, and more;
  • a client plugin (CMSClientPlugin) — added to createCMSClient({ 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:

plugins/page-views.ts
import type { CMSPlugin } from '@createcms/core';

export function pageViews() {
  return { id: 'pageViews' } satisfies CMSPlugin;
}

Register it on the server:

lib/cms.ts
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 is a plain object — see the column reference for the field types:

plugins/page-views.ts
import { definePluginSchema } from '@createcms/core';

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' },
        },
        count: { type: 'integer', notNull: true, default: 0 },
        updatedAt: { type: 'timestamp', notNull: true, defaultNow: true },
      },
    },
  },
});

export function pageViews() {
  return { id: 'pageViews', schema } satisfies CMSPlugin;
}

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:

plugins/page-views.ts
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. They merge into the client's $ERROR_CODES, so consumers can branch on a stable code. Throw with better-call's APIError:

plugins/page-views.ts
import { APIError } from 'better-call';

const $ERROR_CODES = {
  ROOT_NOT_FOUND: { status: 404, message: 'Root not found' },
} as const;

// inside a handler:
throw new APIError(404, {
  code: 'ROOT_NOT_FOUND',
  message: $ERROR_CODES.ROOT_NOT_FOUND.message,
});

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:

plugins/page-views.ts
hooks: {
  after: [
    {
      action: 'deleteRoot',
      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:

plugins/multi-tenant-style.ts
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);
  if (request.method === 'POST' && url.pathname.endsWith('/pageViews/recordView')) {
    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-level createCMS({ onNotification }) option). Use it to mirror notifications to email, Slack, etc.
  • notificationTypes — a Zod map keyed by a notification type, whose values schema that type's meta. Two effects: the keys are folded into the core notification_type enum at createcms generate (so the plugin can persist its own type — re-generate after adding it), and the map is inferred into typeof cms so createNotificationRouter types each meta per type. Raise one with ctx.notificationService.notify({ type, meta, … }).
  • realtimeEvents — a Zod map of events your plugin publishes over the shared realtime transport. It's inferred into typeof cms so a client subscription is typed. (Requires realtime configured.)
  • pruning: { plan, execute } — contribute to runPruningPass: plan returns the deletable rows for a root, execute deletes them in the retention transaction. Use it so your plugin's tables honour dataRetention.
plugins/ab-test.ts
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:

plugins/page-views-client.ts
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: { ROOT_NOT_FOUND: { status: 404, message: 'Root not found' } },
  };
}
lib/cms-client.ts
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 getActions

How the types flow

Plugin contributions reach typeof cms and the client through inference — there's no codegen step:

  • server endpointscms.api.<id>.* and (via typeof cms) client.<id>.*;
  • collectionEndpointscms.api.<collection>.*;
  • client getActionsclient.*;
  • realtimeEvents → the typed realtime event registry;
  • $ERROR_CODES (server + client) → 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
idRequired. Namespaces endpoints + error codes.
endpointsRoutes at cms.api.<id>.<method> (path /<id>/<method>).
collectionEndpointsRoutes mounted on every collection.
schemaTables + enums merged into the generated schema.
hooksbefore / after handlers on CMS actions.
middlewaresPath-bound better-call middleware.
initPer-instance setup; returns context (incl. scopeConditions) + extra hooks.
onRequest / onResponseIntercept / rewrite requests and responses.
onNotificationHandle every emitted notification.
notificationTypesZod meta map; adds notification types (enum + typed router).
realtimeEventsZod event map published over the realtime transport.
pruningplan / execute for data-retention.
$ERROR_CODESTyped error codes.
Client (CMSClientPlugin)What it does
idRequired.
getActionsActions merged onto the client.
initClient-side setup ($fetch, $store).
pathMethodsHTTP method per action path.
$ERROR_CODESTyped error codes on the client.

On this page