Skip to content

Build a Plugin

This walks through packages/plugin-hello, the CMS’s reference plugin, so you can add your own. It assumes you work inside your own clone of the CMS repository. Read Plugin development first for the concepts. The plugin SDK (@kenresoft-cms/plugin-sdk) is internal to the repository, not on npm.

The example plugin, hello, stores greetings. It has one table, three routes, one configurable prefix, one event and one admin page. Replace hello with your own id throughout.

packages/plugin-hello/
├── package.json
├── tsconfig.json
├── eslint.config.js
└── src/
├── manifest.ts
├── config-schema.ts
├── repository.ts
├── routes.ts
└── index.ts

package.json is a private workspace package with main and types pointing at ./src/index.ts (no build step), and dependencies on @hono/zod-openapi, hono, zod, @kenresoft-cms/contracts, @kenresoft-cms/database and @kenresoft-cms/plugin-sdk as workspace:*. Copy the hello plugin’s package.json, tsconfig.json and eslint.config.js and rename.

Add the package to the API Worker’s dependencies in apps/api/package.json: "@kenresoft-cms/plugin-hello": "workspace:*", then run pnpm install.

import { PLUGIN_SDK_VERSION } from '@kenresoft-cms/plugin-sdk';
import type { PluginManifest } from '@kenresoft-cms/plugin-sdk';
export const helloManifest: PluginManifest = {
id: 'hello',
name: 'Hello',
description: 'Greetings, nothing more.',
version: '0.1.0',
sdkVersion: PLUGIN_SDK_VERSION,
capabilities: ['database', 'events'],
permissions: ['hello:greeting:create'],
};

sdkVersion must equal the SDK’s version exactly, otherwise the Worker fails at cold start with a clear error. permissions is documentation only. Enforcement is by role, in the routes.

Non-secret, per-deployment settings, validated by Zod and stored by Core:

import { z } from 'zod';
export const helloConfigSchema = z.object({
greeting: z.string().min(1).default('Hello'),
});
export type HelloConfig = z.infer<typeof helloConfigSchema>;

Read it in a route with await ctx.config.get(). Never put secrets here. Use wrangler secret put.

Tables live in the shared schema package, prefixed plugin_<id>_, in packages/database/schema/plugins/hello.ts:

import { sql } from 'drizzle-orm';
import { sqliteTable, text, integer, index } from 'drizzle-orm/sqlite-core';
export const pluginHelloGreetings = sqliteTable(
'plugin_hello_greetings',
{
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
message: text('message').notNull(),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
},
(table) => [index('plugin_hello_greetings_created_at_idx').on(table.createdAt)],
);

Export it from packages/database/schema/index.ts (export * from './plugins/hello';), then generate and commit the migration before deploying with the plugin enabled:

Terminal window
pnpm --filter @kenresoft-cms/database generate
pnpm --filter @kenresoft-cms/database migrate:local

Only this file queries your plugin’s tables:

import { desc, pluginHelloGreetings } from '@kenresoft-cms/database';
import type { Database } from '@kenresoft-cms/database';
export const listGreetings = (db: Database) =>
db.query.pluginHelloGreetings.findMany({ orderBy: desc(pluginHelloGreetings.createdAt) });
export async function createGreeting(db: Database, message: string) {
const [row] = await db.insert(pluginHelloGreetings).values({ message }).returning();
return row!;
}
import { createRoute, z } from '@hono/zod-openapi';
import { createPluginOpenApiApp, requirePluginRole } from '@kenresoft-cms/plugin-sdk';
import type { PluginBindings, PluginVariables } from '@kenresoft-cms/plugin-sdk';
export const helloRoutes = createPluginOpenApiApp<{ Bindings: PluginBindings; Variables: PluginVariables }>();
helloRoutes.openapi(
createRoute({
method: 'post',
path: '/greetings',
middleware: requirePluginRole('editor'),
request: { body: { content: { 'application/json': { schema: z.object({ message: z.string().min(1) }) } } } },
responses: { 201: { description: 'Created', content: { 'application/json': { schema: z.object({ id: z.string() }) } } } },
}),
async (c) => {
const { message } = c.req.valid('json');
const ctx = c.get('pluginContext');
const config = (await ctx.config.get()) as HelloConfig;
const row = await createGreeting(ctx.db, `${config.greeting}, ${message}`);
ctx.events.emit('hello:greeting.created', { id: row.id });
return c.json({ id: row.id }, 201);
},
);

createPluginOpenApiApp gives you the same 400 validation shape as Core. requirePluginRole('editor') uses Core’s role hierarchy. c.get('pluginContext') is your PluginContext: db, user, hasRole(), media, config, events, email, payments, accounts and logger.

For a storefront-style unauthenticated API, export a second sub-app as publicRoutes. It gets a smaller PluginPublicContext and Core’s public rate limit. Enforce the “draft looks like missing” rule yourself, at the query layer.

import type { PluginRegistration } from '@kenresoft-cms/plugin-sdk';
export const helloPlugin: PluginRegistration<HelloConfig> = {
manifest: helloManifest,
routes: helloRoutes,
configSchema: helloConfigSchema,
};

Then add it to the one file that imports plugins by name, apps/api/src/plugins/registered-plugins.ts:

export const AVAILABLE_PLUGINS: PluginRegistration[] = [helloPlugin, commercePlugin];

Its routes now mount at /api/plugins/hello/v1/* behind a session, and are gated by the live enablement check.

Admin UI is deliberately outside the plugin package, in apps/admin/src/plugins/<id>/, and registered in apps/admin/src/plugins/registry.ts:

export const pluginNavItems: PluginNavItem[] = [
{ pluginId: 'hello', to: '/plugins/hello', label: 'Hello', end: false, icon: Puzzle },
];
export const pluginRoutes = [
{
path: 'plugins/hello',
lazy: async () => ({ Component: (await import('./hello/HelloPage')).HelloPage }),
},
];

pluginId must match the manifest id: a disabled plugin’s nav entry disappears. Add a group to gather several pages under one sidebar heading. The page itself uses the admin’s existing patterns (a TanStack Query hook, an empty state, a role-gated create action).

Terminal window
pnpm typecheck && pnpm lint
pnpm --filter @kenresoft-cms/api test

Deploy with pnpm run update, or locally with pnpm dev. The plugin appears under Installed Plugins, enabled by default. apps/api/test/plugin-hello.test.ts and plugin-registry.test.ts in the repository show how to test the route gate and registry validation against a real database.

  • A plugin’s tables share the one D1 database and migration history. Keep to your own plugin_<id>_* tables by convention.
  • ctx.events is best effort and in-process. Use webhooks for durable delivery.
  • Lifecycle hooks are declared but never run.
  • Plugins cannot contribute block types yet.
  • A new plugin requires a code change and a redeploy. Only enabling or disabling one that is already bundled is live.