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.
1. Create the package
Section titled “1. Create the package”packages/plugin-hello/├── package.json├── tsconfig.json├── eslint.config.js└── src/ ├── manifest.ts ├── config-schema.ts ├── repository.ts ├── routes.ts └── index.tspackage.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.
2. The manifest
Section titled “2. The manifest”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.
3. Config (optional)
Section titled “3. Config (optional)”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.
4. The database table
Section titled “4. The database table”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:
pnpm --filter @kenresoft-cms/database generatepnpm --filter @kenresoft-cms/database migrate:local5. The repository
Section titled “5. The repository”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!;}6. The routes
Section titled “6. The routes”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.
7. Export the registration
Section titled “7. Export the registration”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.
8. The admin page
Section titled “8. The admin page”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).
9. Test and enable
Section titled “9. Test and enable”pnpm typecheck && pnpm lintpnpm --filter @kenresoft-cms/api testDeploy 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.
Limits to remember
Section titled “Limits to remember”- A plugin’s tables share the one D1 database and migration history. Keep to your own
plugin_<id>_*tables by convention. ctx.eventsis 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.