Tutorials9 min read

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.

SonicJS Team

3D isometric plugin blocks connecting to a central CMS hub on a dark background with electric blue accents

Building Your First SonicJS Plugin: A Walkthrough of the Example Plugin

TL;DR β€” Every new SonicJS app scaffolds an example plugin that demonstrates the complete plugin API: routes, admin pages, collections, configurable settings, hooks, and data seeding. Read it β€” then delete it and build your own.

Key Stats:

  • 4 files cover the full plugin surface area
  • Routes live at /example/* (not /api/*) β€” explained below
  • Settings auto-render from a configSchema object β€” no custom form needed
  • Data seeding is idempotent via a COUNT guard in onBoot

Every new SonicJS app you create with npm create sonicjs@latest includes a working plugin called Example. It's not boilerplate to delete immediately β€” it's a guided tour of everything the plugin system can do, annotated with the why, not just the what.

This post walks through the example plugin file by file, explaining the key concepts you need to build your own.


What the Example Plugin Does

At runtime, the example plugin:

  • Serves a public JSON greeting API at /example, /example/:name, and /example/moods
  • Adds an admin page at /admin/example visible in the sidebar
  • Registers a Moods collection you can manage via the admin content UI
  • Seeds three default moods into the database on first boot
  • Exposes a configurable settings form (greeting text, default name, mood selector)
  • Logs to the console on content create/update and user registration events

It's small enough to read in one sitting but covers every major plugin extension point.


File Structure

src/plugins/example/
β”œβ”€β”€ index.ts                      ← plugin definition (start here)
β”œβ”€β”€ routes/
β”‚   β”œβ”€β”€ api.ts                    ← public Hono routes
β”‚   └── admin.ts                  ← admin page route
└── collections/
    └── moods.collection.ts       ← collection config

Your src/index.ts wires it in:

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] },
})

Two things happen here: registerCollections adds the moods collection to the in-memory registry (code-only, no DB table), and plugins.register tells the runtime to call the plugin's register() and onBoot() at the right times.


index.ts β€” The Plugin Definition

This is where everything is declared. The entry point to every SonicJS plugin is definePlugin().

Identity Fields

export const examplePlugin = definePlugin({
  id: 'example',        // stable machine key β€” never change after shipping
  name: 'Example',      // displayed in admin UI
  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 is the most important field. It becomes the database key for settings, the URL segment in /admin/plugins/:id, and the key used by other plugins that declare a dependency. Pick it once and never change it.

Two-Phase Boot: register vs onBoot

SonicJS plugins initialize in two strict phases, matching 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 hooks
  },

Why two phases? Hono locks its router after the first request. If you try to add routes after that, they're silently ignored. register() must be synchronous and mount all routes before any request arrives. onBoot() then handles everything that requires I/O: loading settings from D1, seeding data, registering dynamic hooks.

Why /example/* and Not /api/*

This trips up nearly every first-time plugin author:

  register(app) {
    // βœ… Works β€” mounted at a top-level path
    app.route('/example', createExampleApiRoutes(pluginOptions))

    // ❌ Would be swallowed by core's /api/:collection catch-all
    // app.route('/api/example', createExampleApiRoutes(pluginOptions))
  },

User plugins are registered after the core /api router, which has a /:collection wildcard. In Hono, routes match in registration order, so /api/your-plugin gets caught by the wildcard and returns "Collection not found". The fix: use a top-level prefix like /example or /my-company/my-plugin.

The Menu Entry

  menu: [
    {
      label: 'Example',
      path: '/admin/example',
      icon: 'globe-alt',
      order: 90,
      permissions: ['admin'],
    },
  ],

The catalyst admin layout reads this array from the plugin menu singleton and renders the sidebar entry automatically. No middleware or template changes needed.

Declarative Hooks

  hooks: {
    'content:after:create': (payload) => {
      console.log(`[example] New content in "${payload.collection}"`)
    },
    'auth:registration:completed': (payload) => {
      console.log(`[example] Welcome, ${payload.user.email}!`)
    },
  },

The hooks map is the declarative way to subscribe to typed lifecycle events. Each key is a canonical event name, and payload is TypeScript-narrowed to the right type for that event. For conditional subscriptions (e.g., only subscribe if a config flag is set), use ctx.hooks.on() inside onBoot instead.

onBoot β€” Async Setup

onBoot is where the real work happens:

  async onBoot(ctx) {
    const db = ctx.env?.DB

    // 1. Self-register so the plugin appears in /admin/plugins
    await svc.ensurePlugin('example', { displayName: 'Example', ... })

    // 2. Load existing settings, merge with defaults, apply to route handlers
    const existing = await svc.getPlugin('example')
    const existingSettings = (existing?.settings as Record<string, unknown>) ?? {}
    const mergedSettings = {
      greeting: 'Hello, Cruel World!',
      defaultName: 'Stranger',
      ...existingSettings,  // saved values win
    }
    if (typeof mergedSettings.greeting === 'string') {
      pluginOptions.greeting = mergedSettings.greeting
    }

    // 3. Sync route metadata so the Info tab shows the route list
    await svc.updatePluginSettings('example', {
      ...mergedSettings,
      _adminPath: '/admin/example',
      _routes: [ /* route list */ ],
    })

    // 4. Seed default moods (idempotent COUNT guard)
    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')
      }
    }
  },

Three patterns to remember:

  1. ensurePlugin β€” idempotent write that makes the plugin visible in the admin list without a manifest.json
  2. Settings merge β€” always spread existingSettings last so user-saved values win over defaults. Don't replace β€” merge.
  3. COUNT guard β€” check before seeding so re-warm isolates don't duplicate data

Schema-Driven Settings

  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',
    },
  },

Declaring configSchema auto-renders a settings form in the admin at /admin/plugins/example β€” no custom route or Hono handler needed. The core parses the submitted form data, validates it, and persists it to the documents table. On the next onBoot, your code reads it back and applies it.


routes/api.ts β€” Public Routes

export function createExampleApiRoutes(options: { greeting?: string; defaultName?: string } = {}): Hono {
  const router = new Hono()

  // /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}` : null,
      timestamp: new Date().toISOString(),
      plugin: 'example',
    })
  })

  // /example/moods β€” MUST be before /:name so it's not swallowed
  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 })
  })

  // /example/:name β€” personalised greeting
  router.get('/:name', async (c) => {
    const name = c.req.param('name')
    // ...same pattern as /
  })

  return router
}

Two things to note:

  1. Factory pattern β€” createExampleApiRoutes(options) takes the shared pluginOptions object by reference. When onBoot updates pluginOptions.greeting, the route handler sees the new value immediately β€” no restart needed.

  2. Route order matters β€” /moods must be registered before /:name. Hono matches in registration order, so if /:name came first, a request for /example/moods would be treated as a name param.

The DocumentRepository is the read chokepoint for all document data. Pass it the D1 binding and tenant ID; it handles the SQL, tenant scoping, and status filtering.


collections/moods.collection.ts β€” Collection Config

export const moodsCollection = {
  name: 'example',
  displayName: 'Example',
  schema: {
    type: 'object',
    properties: {
      name: { type: 'string', title: 'Mood Name', required: true },
      emoji: { type: 'string', title: 'Emoji' },
      description: { type: 'string', title: 'Description' },
    },
  },
  access: {
    public: ['read'],   // API readable without auth
  },
} satisfies CollectionConfig

Collections in SonicJS v3 are code-only β€” there's no collections DB table. You define a CollectionConfig object, pass it to registerCollections() at startup, and the bootstrap sequence auto-registers a document_type row. The admin content UI at /admin/content?model=example appears automatically.

The name: 'example' matches the typeId used in onBoot seeding and in the DocumentRepository.list() calls β€” keep them consistent.


Customizing the Example Plugin

Want to change the greeting before building your own plugin? Edit src/plugins/example/index.ts:

// Change defaults
const DEFAULT_MOODS = [
  { name: 'Caffeinated', emoji: 'β˜•', description: 'Monday morning energy.' },
]

// Change identity
export const examplePlugin = definePlugin({
  id: 'my-plugin',      // change this first
  name: 'My Plugin',
  // ...

Or change the greeting via the Admin β†’ Plugins β†’ Example β†’ Settings tab β€” no code needed.


Removing the Example Plugin

When you're ready to build your own plugin, remove these lines from src/index.ts:

// Remove these imports
import { examplePlugin } from './plugins/example'
import { moodsCollection } from './plugins/example/collections/moods.collection'

// Remove from registerCollections
registerCollections([
  blogPostsCollection,
  // moodsCollection,  ← delete
])

// Remove from plugins.register
export default createSonicJSApp({
  plugins: {
    register: [
      // examplePlugin,  ← delete
    ],
  },
})

Then delete the src/plugins/example/ directory. The moods documents in D1 remain until you clean them up manually β€” they don't affect other collections.


What's Next

Now that you understand the plugin structure, build your own:

  1. Create src/plugins/my-plugin/index.ts using definePlugin()
  2. Add routes in register() at a top-level path (e.g. /my-plugin/*)
  3. Load settings and seed data in onBoot()
  4. Optionally add a collection in src/plugins/my-plugin/collections/
  5. Register everything in src/index.ts

The Example Plugin reference documents every API used in this post with full code blocks and a section-by-section breakdown.

The Plugin Development Guide covers hooks, capabilities, cron support, and admin interface patterns.

The example plugin source lives at src/plugins/example/ in every new install. Keep a tab open on it while you write your own.

#plugins#tutorial#cloudflare#typescript

Share this article

Related Articles

Isometric illustration of files flowing from a client device into Cloudflare R2 storage cylinders over glowing blue trajectories
Tutorials

File Uploads with SonicJS and Cloudflare R2

Upload, validate, and serve images, video, and documents with SonicJS and Cloudflare R2 β€” multipart uploads, MIME checks, signed URLs, and image transforms.

Isometric illustration of SonicJS content collections architecture with stacked content cards, field shapes, and glowing connections to a central edge database
Tutorials

Creating Custom Collections in SonicJS

Build production-ready content models in SonicJS with TypeScript-first collections, 30+ field types, references, validation, and auto-generated REST endpoints.