Example Plugin
A fully-featured reference plugin that ships with every new SonicJS app. Read it to learn the plugin system, then delete it and build your own.
Overview
The example plugin demonstrates every major extension point in the SonicJS v3 plugin system. It is not a toy — it uses real routes, real collections, real settings, and real data seeding patterns that you can copy directly into your own plugins.
Public API Routes
GET /example, GET /example/:name, GET /example/moods — served at a top-level path to avoid the /api/:collection catch-all
Admin Page
Custom admin page at /admin/example with automatic sidebar menu entry
Moods Collection
Code-defined collection registered via registerCollections() — no DB table required
Schema-Driven Settings
configSchema auto-renders a settings form in /admin/plugins/example — no custom form needed
Lifecycle Hooks
Declarative subscriptions to content:after:create and auth:registration:completed events
Idempotent Data Seeding
onBoot seeds three default moods with a COUNT guard — safe across isolate restarts
Remove the example plugin from src/index.ts and delete src/plugins/example/ when you're ready to ship your own. It has no production use — it's a learning aid.
File Structure
src/plugins/example/
├── index.ts ← definePlugin() entry point (start here)
├── routes/
│ ├── api.ts ← public Hono routes
│ └── admin.ts ← admin page route
└── collections/
└── moods.collection.ts ← CollectionConfig for the Moods collection
Wired into src/index.ts:
import { createSonicJSApp, registerCollections } from '@sonicjs-cms/core'
import { examplePlugin } from './plugins/example'
import { moodsCollection } from './plugins/example/collections/moods.collection'
registerCollections([moodsCollection])
export default createSonicJSApp({
plugins: { register: [examplePlugin] },
})
Plugin Definition
Every SonicJS plugin is a PluginConfig object created by definePlugin(). The identity fields are set once and never changed:
export const examplePlugin = definePlugin({
id: 'example', // stable machine key — used as DB key, URL segment, dependency ref
name: 'Example', // displayed in /admin/plugins list
version: '1.0.0',
description: 'A demo plugin that explains the SonicJS v3 plugin system.',
sonicjsVersionRange: '^3.0.0',
author: { name: 'You', email: 'you@example.com', url: 'https://example.com' },
// ...
})
The id becomes:
- The key under which settings are stored in the
documentstable - The URL segment in
/admin/plugins/example - The identifier used by other plugins that declare
dependencies: ['example']
Two-Phase Boot
SonicJS plugins initialize in two strict phases that match the Cloudflare Workers execution model:
// Phase 1 — SYNCHRONOUS — must complete before any request arrives
register(app) {
app.route('/example', createExampleApiRoutes(pluginOptions) as any)
app.route('/admin/example', createExampleAdminRoutes(pluginOptions) as any)
},
// Phase 2 — ASYNC — runs once per Worker isolate on the first request
async onBoot(ctx) {
// safe to: await DB queries, read env bindings, register conditional hooks
},
Why two phases? Hono locks its router after the first request. Routes added after that are silently ignored. register() must be synchronous and mount all routes while the app is still initializing. onBoot() handles everything requiring I/O: loading settings from D1, seeding default data, registering conditional hooks.
The pluginOptions object is passed by reference from register into onBoot. When onBoot mutates pluginOptions.greeting, the route handlers created in register see the new value immediately — no restart required.
Public Routes
Routes live at /example/* — not /api/example/*. User plugins mount after the core /api/:collection catch-all. In Hono, routes match in registration order, so any path starting with /api/ gets swallowed before your handler runs. Use a top-level prefix.
routes/api.ts
export function createExampleApiRoutes(
options: { greeting?: string; defaultName?: string } = {}
): Hono {
const router = new Hono()
// GET /example — greeting with random mood
router.get('/', async (c) => {
const db = (c.env as any)?.DB
const mood = db ? await getRandomMood(db) : null
return c.json({
message: `Hello, ${options.defaultName ?? 'Stranger'}! The world is still quite cruel.`,
mood: mood ? `${mood.emoji} ${mood.name}`.trim() : null,
moodDescription: mood?.description ?? null,
timestamp: new Date().toISOString(),
plugin: 'example',
})
})
// GET /example/moods — must be BEFORE /:name
router.get('/moods', async (c) => {
const repo = new DocumentRepository((c.env as any).DB, 'default')
const docs = await repo.list({ typeId: 'example', status: 'published', limit: 100 })
return c.json({ moods: docs.map(d => d.data), total: docs.length })
})
// GET /example/:name — personalised greeting (registered AFTER /moods)
router.get('/:name', async (c) => {
const name = c.req.param('name')
// ... same pattern as GET /
})
return router
}
Route order matters: /moods must be registered before /:name. Hono matches in registration order, so if /:name came first, GET /example/moods would be caught as a name param.
DocumentRepository is the read chokepoint for all document data. Pass it the D1 binding and tenant ID; it handles SQL, tenant scoping, and status filtering (R4 from the architecture rules).
API Response Examples
GET /example
curl http://localhost:8787/example
GET /example/:name
curl http://localhost:8787/example/Alice
GET /example/moods
curl http://localhost:8787/example/moods
Admin Page
The admin route is mounted at /admin/example and registered in register() alongside the API routes:
register(app) {
app.route('/example', createExampleApiRoutes(pluginOptions) as any)
app.route('/admin/example', createExampleAdminRoutes(pluginOptions) as any)
},
The sidebar entry is declared in the plugin's menu array:
menu: [
{
label: 'Example',
path: '/admin/example',
icon: 'globe-alt',
order: 90,
permissions: ['admin'],
},
],
The catalyst admin layout reads menu from the plugin registry and renders the sidebar entry automatically. No middleware or template modifications required.
Moods Collection
Collections in SonicJS v3 are code-only — no DB table. You define a CollectionConfig, pass it to registerCollections(), and the bootstrap sequence auto-registers a document_type row. The admin content UI at /admin/content?model=example appears automatically.
collections/moods.collection.ts
export const moodsCollection = {
name: 'example', // must match typeId used in DocumentRepository queries
displayName: 'Example',
slug: 'example',
description: 'Moods served by the Example plugin API',
icon: '😈',
schema: {
type: 'object',
properties: {
name: { type: 'string', title: 'Mood Name', required: true, maxLength: 100 },
emoji: { type: 'string', title: 'Emoji', maxLength: 10 },
description: { type: 'string', title: 'Description', maxLength: 300 },
},
required: ['name'],
},
listFields: ['name', 'emoji', 'description'],
searchFields: ['name', 'description'],
defaultSort: 'createdAt',
defaultSortOrder: 'asc' as const,
managed: true,
isActive: true,
access: {
public: ['read'], // readable by GET /example/moods without auth
},
} satisfies CollectionConfig
The name: 'example' ties the collection to the typeId used everywhere else: DocumentRepository.list({ typeId: 'example' }), DocumentsService.create({ typeId: 'example', ... }), and the seeding COUNT guard. Keep them consistent.
Settings
Declaring configSchema in the plugin definition auto-renders a settings form at /admin/plugins/example — no custom Hono handler needed.
configSchema: {
greeting: {
type: 'string',
label: 'Custom Greeting',
default: 'Hello, Cruel World!',
},
defaultName: {
type: 'string',
label: 'Default Name',
default: 'Stranger',
},
showTimestamp: {
type: 'boolean',
label: 'Show Timestamp in API Response',
default: true,
},
mood: {
type: 'select',
label: 'Mood',
options: [
{ value: 'cruel', label: '😈 Cruel (default)' },
{ value: 'kind', label: '😇 Kind' },
{ value: 'indifferent', label: '😐 Indifferent' },
],
default: 'cruel',
},
},
The core validates and persists submitted form data to the documents table. On the next onBoot, your plugin reads the saved values and applies them:
async onBoot(ctx) {
const existing = await svc.getPlugin('example')
const existingSettings = (existing?.settings as Record<string, unknown>) ?? {}
const mergedSettings = {
greeting: 'Hello, Cruel World!', // default
defaultName: 'Stranger', // default
...existingSettings, // saved values win
}
if (typeof mergedSettings.greeting === 'string') {
pluginOptions.greeting = mergedSettings.greeting
}
// Always write back with _adminPath and _routes preserved:
await svc.updatePluginSettings('example', {
...mergedSettings,
_adminPath: '/admin/example',
_routes: [ /* route list */ ],
})
}
Always spread existingSettings after defaults so saved values win. Always include _adminPath and _routes in the write-back — the admin plugin info tab reads them.
Hooks
Hooks are the declarative way to subscribe to typed lifecycle events. The hooks map in the plugin definition is always active:
hooks: {
'content:after:create': (payload) => {
console.log(`[example] New content in "${payload.collection}"`)
},
'auth:registration:completed': (payload) => {
console.log(`[example] Welcome, ${payload.user.email}!`)
},
},
For conditional subscriptions (e.g., only subscribe when a config flag is on), use ctx.hooks.on() inside onBoot instead:
async onBoot(ctx) {
if (pluginOptions.verboseLogging) {
ctx.hooks.on('content:after:update', (payload) => {
console.log('[example] Content updated:', payload.id)
})
}
}
Data Seeding
onBoot seeds three default moods on first run. A COUNT guard prevents duplicates across isolate restarts:
async onBoot(ctx) {
const db = ctx.env?.DB
const countRow = await db
.prepare(`SELECT COUNT(*) as n FROM documents WHERE type_id = ? AND tenant_id = ?`)
.bind('example', 'default')
.first()
if ((countRow?.n ?? 0) === 0) {
for (const mood of DEFAULT_MOODS) {
await svc.create(
{ typeId: 'example', data: mood, publishOnCreate: true },
'system'
)
}
}
}
DocumentsService.create() handles the raw SQL batch (R1), tenant scoping (R3), and version numbering (R6). The publishOnCreate: true flag makes the moods immediately available via GET /example/moods without a separate publish step.
The three default moods:
| Name | Emoji | Description |
|---|---|---|
| Cruel | 😈 | Default adversarial energy. |
| Kind | 😇 | Surprising warmth from an unexpected source. |
| Chaotic | 🌀 | Unpredictable in the best possible way. |
Customizing
Change the defaults by editing src/plugins/example/index.ts:
// Change the seed data
const DEFAULT_MOODS = [
{ name: 'Caffeinated', emoji: '☕', description: 'Monday morning energy.' },
{ name: 'Focused', emoji: '🎯', description: 'Deep work mode.' },
]
// Change the plugin identity (do this first)
export const examplePlugin = definePlugin({
id: 'my-plugin', // ← pick a stable id before shipping
name: 'My Plugin',
// ...
})
Or configure via the admin UI without touching code: Admin → Plugins → Example → Settings.
Removing
When you're ready to build your own plugin, remove the example from src/index.ts:
// Remove these imports
import { examplePlugin } from './plugins/example'
import { moodsCollection } from './plugins/example/collections/moods.collection'
// Remove from registerCollections([...])
// Remove examplePlugin from plugins.register: [...]
Then delete src/plugins/example/. The moods documents in D1 remain until you clean them up manually — they don't affect other collections.