SonicJS Plugins: How to Extend Your CMS
Build, configure, and ship SonicJS plugins with TypeScript โ custom routes, lifecycle hooks, DB-backed settings, and admin pages on Cloudflare Workers.

SonicJS Plugins: How to Extend Your CMS
TL;DR โ SonicJS v3 ships with definePlugin and 28 first-party plugins you can study or fork. Plugins register custom routes, admin menu items, and event hooks via onBoot. Settings are typed with Zod configSchema. Plugin data lives in the document repository โ no new tables. registerPlugins(app, [...], { hookSystem, env }) wires everything in.
Key Stats:
- 28 first-party core plugins shipped in
@sonicjs-cms/core onBoot(ctx)lifecycle for hooks and document-type registration- 20+ standard event hooks across auth, content, media, and request lifecycles
- Extension points: routes, admin menu items, document types, event hooks
- No manifest.json โ plugins are pure TypeScript objects registered in code
Most CMS platforms treat extensibility as an afterthought โ a webhook here, a "custom field" dropdown there. SonicJS takes the opposite stance: plugins are how features get built. Authentication, OAuth, OTP login, analytics, Stripe, the rich-text editors, even the demo seed data โ all plugins. The same SDK you'd use to add a side feature is the one the core team uses to build the platform.
That has a useful side effect: when you write your own plugin, you're not on a private side road. You're using the same lifecycle hooks, the same context object, and the same registration entry point as 28 first-party plugins you can read end-to-end.
This guide walks through the plugin system as it actually exists in packages/core/src/plugins: how a plugin is shaped, how it registers, how to expose routes, how settings persist via typed config schemas, and how to publish your work to npm. Every example maps to real code from the SonicJS repo.
Why the Plugin Model Matters
A plugin in SonicJS is a plain TypeScript object that conforms to the Plugin interface. There is no compiled DSL, no YAML, no separate runtime. You build a plugin, hand it to createSonicJS({ plugins: [...] }), and it gets wired into the same Hono app that serves the rest of the CMS.
Three properties make the model worth using:
- One shape, two pathways. A plugin can live inside your app (
src/plugins/) or be published to npm. The interface is identical. - Hot-swappable. Activating, configuring, or deactivating a headless CMS plugin at runtime works through the admin UI without restarts.
- Edge-native. Because plugins receive a
PluginContextwithdb,kv, andr2bindings, they're as comfortable on Cloudflare Workers as the core platform.
For a high-level catalog of what already exists, see the plugins index. For deeper API docs, the development guide is the canonical reference.
The Plugin Interface
Every plugin is a plain object created with definePlugin from @sonicjs-cms/core:
import { definePlugin } from '@sonicjs-cms/core'
const myPlugin = definePlugin({
id: 'my-plugin',
name: 'My Plugin',
version: '1.0.0',
description: 'Optional description',
// Schema-driven config (auto-generates the admin settings form)
configSchema: {
apiKey: { type: 'string', label: 'API Key' },
webhookUrl: { type: 'string', label: 'Webhook URL', format: 'url' },
},
// Routes registered directly on the Hono app (must be synchronous)
register(app) {
app.get('/my-route', handler)
},
// Admin sidebar entries
menu: [
{ label: 'My Page', path: '/admin/my-page', icon: 'IconName', order: 100 }
],
// Boot-time: hooks, document-type registration, etc.
onBoot(ctx) {
ctx.hooks.on('content:after:update', async (data) => {
// transform or validate
return data
})
},
})
definePlugin is a lightweight identity function โ it returns the object as-is with full TypeScript inference. No builder chain, no compilation step.
A Minimal "Hello Plugin"
The smallest meaningful plugin in the SonicJS repo is the hello-world-plugin. Stripped of HTML, it looks like this:
// src/plugins/hello-world/index.ts
import { html } from 'hono/html'
import { definePlugin } from '@sonicjs-cms/core'
export const helloWorldPlugin = definePlugin({
id: 'hello-world',
name: 'Hello World',
version: '1.0.0',
description: 'A simple Hello World plugin demonstration',
register(app) {
app.get('/admin/hello-world', async (c: any) => {
const user = c.get('user') as { email?: string } | undefined
return c.html(html`<h1>Hello, ${user?.email ?? 'world'}!</h1>`)
})
},
menu: [
{ label: 'Hello World', path: '/admin/hello-world', icon: 'HandRaisedIcon', order: 90 },
],
onBoot(_ctx) {
console.info('[hello-world] booted')
},
})
Four things are happening:
- Identity โ
id,name, andversionare the required fields. - Route โ the handler is registered directly on the Hono app inside
register(app). - Menu item โ appears in the admin sidebar with an icon and ordering.
onBootโ runs at bootstrap; use it for hook registration, document-type registration, or any one-time setup.
Drop this file into src/plugins/hello-world/index.ts and pass it to registerPlugins. SonicJS picks it up at boot.
Registering Plugins with registerPlugins
Plugins compose into a SonicJS app via registerPlugins. There is no manifest.json and no auto-discovery โ plugins are plain TypeScript objects you hand to the function:
// src/index.ts
import { Hono } from 'hono'
import {
registerPlugins,
authPlugin,
oauthProvidersPlugin,
otpLoginPlugin,
analyticsPlugin,
emailPlugin,
} from '@sonicjs-cms/core'
import { helloWorldPlugin } from './plugins/hello-world'
import { postsCollection } from './collections/posts'
const app = new Hono<{ Bindings: Env }>()
// registerPlugins wires routes, menu items, and onBoot callbacks
registerPlugins(app, [
// First-party plugins
authPlugin,
emailPlugin,
oauthProvidersPlugin,
otpLoginPlugin,
analyticsPlugin,
// Your own plugins
helloWorldPlugin,
], { hookSystem, env })
export default app
registerPlugins iterates the array in order: calls register(app) for routes, merges menu entries into the sidebar, then calls onBoot(ctx) for each plugin. No manifest scanning, no database lookup at startup.
Plugin with Custom Routes
Routes are the most common extension point. Register them directly on the Hono app inside register(app):
import { z } from 'zod'
import { definePlugin, requireAuth, requireRole } from '@sonicjs-cms/core'
const newsletterSchema = z.object({
email: z.string().email(),
source: z.string().max(50).optional(),
})
export const newsletterPlugin = definePlugin({
id: 'newsletter',
name: 'Newsletter',
version: '0.1.0',
description: 'Lightweight email signup list',
register(app) {
// Public API: anyone can subscribe
app.post('/api/newsletter/subscribe', async (c: any) => {
const body = await c.req.json()
const parsed = newsletterSchema.safeParse(body)
if (!parsed.success) {
return c.json({ error: 'Invalid', details: parsed.error.issues }, 400)
}
// Store directly in D1 using the document model
const db = c.env.DB
const id = crypto.randomUUID()
await db.prepare(
`INSERT INTO documents (id, root_id, type_id, data, tenant_id, type_version, version_number, is_current_draft, is_published, status)
VALUES (?, ?, 'newsletter-subscriber', ?, 'default', 1, 1, 1, 0, 'published')`
).bind(id, id, JSON.stringify({ email: parsed.data.email, source: parsed.data.source ?? null })).run()
return c.json({ success: true })
})
// Admin API: only admins can list
app.get('/api/admin/newsletter/list', requireAuth(), requireRole('admin'), async (c: any) => {
const db = c.env.DB
const { results } = await db.prepare(
`SELECT id, data FROM documents WHERE type_id = 'newsletter-subscriber' AND tenant_id = 'default' AND is_current_draft = 1`
).all()
return c.json({ data: results.map((r: any) => ({ id: r.id, ...JSON.parse(r.data) })) })
})
},
})
Two routes, two privacy levels, one plugin. The requireAuth() and requireRole() middleware are the same ones documented in the SonicJS authentication guide โ your plugin gets the platform's auth model for free. Subscriber records live in the documents table; no custom table needed.
Plugin with Settings (Zod configSchema)
The most underappreciated piece of the plugin system is typed settings. Instead of a free-form JSON blob in a plugins table, v3 plugins declare a Zod configSchema. The platform validates config at boot, surfaces a typed settings form in the admin UI, and passes the parsed config into your handlers.
This is the pattern used by the OTP login and OAuth providers plugins:
import { definePlugin } from '@sonicjs-cms/core'
export const newsletterPlugin = definePlugin({
id: 'newsletter',
name: 'Newsletter',
version: '0.1.0',
// 1. Declare the shape โ platform auto-generates the admin settings form
configSchema: {
doubleOptIn: { type: 'boolean', label: 'Double opt-in', default: true },
fromAddress: { type: 'string', label: 'From address', format: 'email', default: 'hello@example.com' },
welcomeSubject: { type: 'string', label: 'Welcome subject', default: 'Welcome!' },
rateLimitPerHour: { type: 'number', label: 'Rate limit (per hour)', default: 30, min: 1 },
},
register(app) {
app.post('/api/newsletter/subscribe', async (c: any) => {
// 2. Settings are loaded from the DB โ read via SettingsService in your handler
// const settings = await new SettingsService(c.env.DB).getCategorySettings('newsletter')
// ...rate-limit by settings.rateLimitPerHour, send welcome email from settings.fromAddress
})
},
})
Admins edit settings through the auto-generated form at /admin/settings/plugins/newsletter. No plugins table to query, no JSON.parse/merge boilerplate โ the platform handles validation and persistence.
configSchema fields use a simple record format: type ('string' | 'number' | 'boolean' | 'select'), label, optional default, and type-specific extras like format, min/max, or options. This is exactly how hello-world-plugin and other first-party plugins declare their settings.
Hooking Into Auth and Content Events
The hook system is how plugins talk to each other and to the core. SonicJS exposes a typed event catalog โ subscribe via ctx.hooks.on(eventName, handler) inside onBoot. Handlers receive the event payload, can mutate it, and return the (possibly modified) data.
Typed catalog hooks (preferred โ TypeScript-narrowed payload):
| Event name | When it fires |
|---|---|
content:after:create | After a document is created |
content:after:update | After a document is updated (also content:save โ deprecated alias) |
content:after:delete | After a document is deleted |
content:after:publish | After a document is published (also content:publish โ deprecated alias) |
content:read | When a document is read |
auth:registration:completed | After a user self-registers |
auth:password-reset:requested | When a password reset is requested |
auth:password-reset:completed | After a password reset is confirmed |
auth:magic-link:consumed | After a magic-link is used |
auth:otp:verified | After an OTP code is verified |
import { definePlugin } from '@sonicjs-cms/core'
export const auditPlugin = definePlugin({
id: 'audit',
name: 'Audit',
version: '0.1.0',
onBoot(ctx) {
// Auto-tag posts with publish timestamp
ctx.hooks.on('content:after:publish', async (data) => {
data.publishedAt = data.publishedAt ?? Math.floor(Date.now() / 1000)
return data
})
// Mirror new users into a CRM after registration
ctx.hooks.on('auth:registration:completed', async (data) => {
await fetch('https://crm.example.com/contacts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: data.user.email, source: 'sonicjs' }),
})
return data
})
},
})
Legacy bus hooks (auth lifecycle, request lifecycle) subscribe via ctx.raw:
onBoot(ctx) {
// auth:login, auth:logout, request:start are on the legacy hook bus
const rawHooks = (ctx.raw as any)?.hooks
if (rawHooks?.register) {
rawHooks.register('auth:login', async (data: any) => {
console.info(`Login: ${data.email}`)
return data
}, 10) // third arg = priority; lower runs first
}
}
If you need to push events out of your CMS rather than handle them in-process, that's the webhooks system โ different wiring, same vocabulary of event names.
You can also define custom hooks to let other plugins extend yours. Pick a name (my-plugin:before-send) and call ctx.hooks.emit('my-plugin:before-send', payload). Any handler registered for that name participates.
A More Complete Plugin: Routes + Settings + Hooks
Putting the three pieces together, here's a plugin that hooks into auth events, exposes a route, and reads a typed config โ all without creating any new tables:
import type { D1Database } from '@cloudflare/workers-types'
import { definePlugin, requireAuth } from '@sonicjs-cms/core'
export const loginAuditPlugin = definePlugin({
id: 'login-audit',
name: 'Login Audit',
version: '0.1.0',
description: 'Append-only log of every login attempt',
// Schema-driven settings โ auto-generates the admin form
configSchema: {
logFailedLogins: { type: 'boolean', label: 'Log failed logins', default: true },
logSuccessfulLogins: { type: 'boolean', label: 'Log successful logins', default: false },
retentionDays: { type: 'number', label: 'Retention (days)', default: 90, min: 1 },
},
async onBoot(ctx) {
// auth:login is on the legacy hook bus โ subscribe via ctx.raw
const rawHooks = (ctx.raw as any)?.hooks
if (!rawHooks?.register) return
rawHooks.register('auth:login', async (data: any) => {
const db = ctx.env?.DB as D1Database | undefined
if (!db) return data
const success = !!data.userId
// Read settings from SettingsService (persisted by the admin UI)
const { SettingsService } = await import('@sonicjs-cms/core/services')
const svc = new SettingsService(db)
const config = await svc.getCategorySettings('login-audit')
const logSuccess: boolean = config?.logSuccessfulLogins ?? false
const logFailed: boolean = config?.logFailedLogins ?? true
if (success && !logSuccess) return data
if (!success && !logFailed) return data
const id = crypto.randomUUID()
await db.prepare(
`INSERT INTO documents (id, root_id, type_id, data, tenant_id, type_version, version_number, is_current_draft, is_published, status)
VALUES (?, ?, 'login-audit-entry', ?, 'default', 1, 1, 1, 0, 'published')`
).bind(id, id, JSON.stringify({ email: data.email, success, ip: data.ip ?? null })).run()
return data
}, 10)
},
register(app) {
// Admin route: read the audit log directly from D1
app.get('/api/admin/login-audit', requireAuth(), async (c: any) => {
const db: D1Database = c.env.DB
const { results } = await db.prepare(
`SELECT id, data FROM documents
WHERE type_id = 'login-audit-entry' AND tenant_id = 'default' AND is_current_draft = 1
ORDER BY rowid DESC LIMIT 200`
).all()
return c.json({ data: results.map((r: any) => ({ id: r.id, ...JSON.parse(r.data) })) })
})
},
menu: [
{ label: 'Login Audit', path: '/admin/login-audit', icon: 'ShieldCheckIcon', order: 80 },
],
})
That's a fully production-shaped plugin: schema-driven config, an onBoot hook, a protected route, and no CREATE TABLE anywhere. Note that auth:login lives on the legacy hook bus (accessed via ctx.raw) while content lifecycle events are on the typed catalog (ctx.hooks.on). Compare to packages/core/src/plugins/core-plugins/security-audit-plugin/ for the same shape in a core plugin.
Publishing Your Plugin to npm
Once your plugin is useful to more than one project, ship it. The shape SonicJS expects from a published plugin is identical to one in src/plugins/ โ only the import path changes.
A typical layout:
my-org-sonicjs-newsletter/
โโโ package.json
โโโ tsconfig.json
โโโ README.md
โโโ src/
โโโ index.ts
package.json should declare @sonicjs-cms/core as a peer dependency so consumers don't double-install:
{
"name": "@my-org/sonicjs-newsletter",
"version": "0.1.0",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": ["dist"],
"exports": {
".": { "import": "./dist/index.js", "types": "./dist/index.d.ts" }
},
"peerDependencies": {
"@sonicjs-cms/core": "^3.0.0",
"hono": "^4.0.0"
},
"scripts": {
"build": "tsc",
"prepublishOnly": "npm run build"
}
}
Then publish:
npm run build
npm publish --access public
Consumers install and register exactly like a first-party plugin:
npm install @my-org/sonicjs-newsletter
import { newsletterPlugin } from '@my-org/sonicjs-newsletter'
registerPlugins(app, [authPlugin, newsletterPlugin], { hookSystem, env })
Two conventions worth following:
- Export a configured factory and a zero-config instance โ
createNewsletterPlugin({ fromAddress: '...' })for users who need customization,newsletterPluginfor the zero-config path. - No manifest.json needed. The plugin object is the contract โ everything the platform needs is declared on it (
id,configSchema,menu,register,onBoot).
Best Practices
A short list of things that separate a hobby plugin from one you'd run in production:
- Validate everything with Zod. Every route input. Every
configSchema. Every hook payload you emit. The first-party plugins do this without exception. - Declare
configSchemawith.default()on every field. The platform validates on boot โ a plugin without defaults will fail if an admin hasn't configured it yet. - Use
onBootfor side effects. Register document types, subscribe to hooks, and initialize in-memory state here.register(app)is for routes only. - Store data in the document repository, not new tables. Register a
document_typeinonBootand useDocumentRepository/DocumentsServicefor reads and writes (R4). NoCREATE TABLE, noDROP TABLE. - Prefer named icon components for menu items (
ShieldCheckIcon,ChartBarIcon,CogIcon). The admin sidebar resolves them automatically. - Don't ship secrets. Config schema fields are admin-editable but they're not secrets storage. Real secrets belong in Cloudflare Worker secrets, surfaced via env bindings.
- Document the configSchema in your README. The auto-generated admin form is convenient but not self-documenting.
For configuration patterns, the code examples plugin is the easiest first-party plugin to read end-to-end โ it covers the same ground as a CRUD plugin you might write yourself.
Next Steps
You now have the full picture: definePlugin, registerPlugins, route registration, configSchema-typed settings, onBoot hooks, event hooks, and the npm publish path. Where to go from here:
- Plugin development guide โ full API reference for
definePluginandregisterPlugins - Plugins catalog โ 28 first-party plugins to read or fork
- OAuth plugin internals โ production-grade plugin with multi-provider settings and KV-cached state
- Code examples plugin โ minimal CRUD plugin walkthrough
- Hooks reference โ every standard event name and payload shape
- Webhooks โ push events out of your CMS to external services
- Authentication โ the auth primitives plugins compose against
Key Takeaways
- A SonicJS plugin is a plain TypeScript object created with
definePluginโ no builder chain, no YAML, no manifest. - The same shape works for internal plugins (
src/plugins/) and published packages on npm; only the import path changes. - No
manifest.jsonโ plugins are registered in code viaregisterPlugins(app, [...], { hookSystem, env }). - Typed
configSchema(Zod) replaces free-form JSON in apluginstable โ the platform validates on boot and generates the admin form. onBoot(ctx)is the single lifecycle hook for side effects: document-type registration, hook subscriptions, in-memory init.- Event hooks like
content:after:publish(typed catalog) andauth:login(legacy bus viactx.raw) let plugins compose without coupling to one another.
Have an idea for a plugin or want feedback on one you're building? Join us on Discord or open a discussion on GitHub.
Happy extending!
Related Articles

Inside the SonicJS Plugin Architecture: A Deep Dive
Tour the SonicJS plugin internals โ registration order, lifecycle hooks, configSchema settings, route mounting, and how core, available, and user plugins coexist.

Building Your First SonicJS Plugin: A Walkthrough of the Example Plugin
Learn the SonicJS v3 plugin system by dissecting the example plugin that ships with every new install โ routes, collections, settings, hooks, and data seeding explained.

Using SonicJS with Next.js: A Complete Integration Guide
Build edge-fast content sites with Next.js 15 App Router and SonicJS โ typed fetch helpers, RSC, ISR, generateStaticParams, and Cloudflare Pages deployment.