Plugin Development Guide

Create custom plugins to extend SonicJS with new features, routes, admin pages, and more.


Overview

The SonicJS plugin system provides a powerful, modular architecture for extending functionality. Plugins can:

  • Add custom API routes
  • Register middleware
  • Create admin pages and menu items
  • Register document types for structured data
  • Hook into system events
  • Expose capabilities to other plugins

Key Features:

🔌

Modular Architecture

Self-contained plugins with routes, capabilities, and templates

🪝

Event-Driven

Hook into content, auth, and system events

Hot-Swappable

Enable or disable plugins without restarting

🛡️

Isolated

Scoped hooks prevent cross-plugin interference


Getting Started

Prerequisites

  • TypeScript knowledge
  • Understanding of Hono.js framework
  • Familiarity with SonicJS architecture
  • Node.js 18+ installed

Plugin Location

Custom plugins live in your app's src/plugins/ directory. When you create a SonicJS app, this folder is set up for you to add your own plugins.

Use the bundled example plugin as your starting point. Every new SonicJS app (npm create sonicjs@latest) includes a working example plugin at src/plugins/example/. It covers routes, admin pages, collections, settings, and data seeding — read it before writing from scratch.


Plugin Structure

A typical plugin has this structure:

  • index.ts - Main plugin file (defines and exports the plugin via definePlugin)
  • routes.ts - API routes (optional)
  • services/ - Business logic services (optional)
  • tests/ - Plugin tests (optional)

There is no manifest.json — all metadata lives inside definePlugin({...}).


Creating a Plugin

Let's create a simple plugin step by step.

Step 1: Create the Plugin Directory

mkdir -p src/plugins/my-plugin

Step 2: Create the Plugin Code

Create src/plugins/my-plugin/index.ts:

import { definePlugin } from '@sonicjs-cms/core'
import { html } from 'hono/html'

export const myPlugin = definePlugin({
  id: 'my-plugin',
  name: 'My Plugin',
  version: '1.0.0',
  description: 'My first SonicJS plugin',
  author: 'Your Name',

  menu: [
    {
      label: 'My Plugin',
      path: '/admin/my-plugin',
      icon: 'puzzle-piece',
      order: 100,
    },
  ],

  register(app) {
    app.get('/admin/my-plugin', async (c) => {
      const user = c.get('user')
      return c.html(html`<h1>Hello from My Plugin!</h1>`)
    })
  },

  async activate(ctx) {
    console.info('My plugin activated!')
  },

  async deactivate(ctx) {
    console.info('My plugin deactivated!')
  },
})

Step 3: Register the Plugin

In src/index.ts, import your plugin and pass it to registerPlugins:

import { registerPlugins } from '@sonicjs-cms/core'
import { myPlugin } from './plugins/my-plugin'

// after app + hookSystem are created:
registerPlugins(app, [myPlugin], { hookSystem, env })

Plugins are explicitly registered — SonicJS does not auto-discover plugins from directories or scan for manifest files.


Two-Phase Boot

Every plugin goes through two distinct phases. Understanding this split prevents the most common plugin bugs.

PhaseFunctionWhen it runsWhat it can do
Mountregister(app)At app construction, before any requestAdd routes only
WireonBoot(ctx)On the first request, after all plugins have mountedSubscribe hooks, access env, seed DB

Why the split?

Hono locks its router after the first request — any app.route() or app.get() call made after that point silently has no effect. Routes must therefore be declared synchronously during construction, before any request arrives.

onBoot runs lazily on the first request because that is the first moment the Cloudflare env (D1, KV, R2 bindings) is available. All async setup — hook subscriptions, DB seeding, registering document types — belongs in onBoot.

export const myPlugin = definePlugin({
  id: 'my-plugin',
  version: '1.0.0',

  // PHASE 1 — sync, routes only. No await, no env access.
  register(app) {
    app.get('/api/my-plugin/items', async (c) => {
      // env IS available inside a request handler (c.env)
      const db = c.env.DB
      const rows = await db.prepare('SELECT id FROM documents WHERE type_id = ?')
        .bind('my-plugin-item').all()
      return c.json({ data: rows.results })
    })
  },

  // PHASE 2 — async, runs once on first request.
  async onBoot(ctx) {
    ctx.hooks.on('content:after:create', (payload) => {
      console.info('Created:', payload.id)
    })
    // seed document type, etc.
  },
})

register must be synchronous. If register returns a Promise, SonicJS throws PluginRegisterMustBeSyncError at startup. Move all async/await work to onBoot.


Routes

Routes define API endpoints using Hono.js. Add them inside the register(app) function.

Basic Route Registration

import { definePlugin } from '@sonicjs-cms/core'

export const myPlugin = definePlugin({
  id: 'my-plugin',
  name: 'My Plugin',
  version: '1.0.0',

  register(app) {
    app.get('/api/my-plugin/items', async (c) => {
      return c.json({ success: true, data: [] })
    })

    app.post('/api/my-plugin/items', async (c) => {
      const body = await c.req.json()
      return c.json({ success: true, data: body }, 201)
    })
  },
})

Routes are mounted directly on the Hono app instance — no separate route options object is needed. register must be synchronous (see Two-Phase Boot).


Lifecycle Hooks

Plugins have five lifecycle callbacks, all defined as top-level keys in definePlugin.

1. onBoot

Called once on the first request, after every plugin has mounted. This is the primary place for hook subscriptions and env-dependent setup. Receives a fully typed context.

definePlugin({
  // ...
  capabilities: ['hooks.content:subscribe'],

  async onBoot(ctx) {
    // typed hook subscription — payload is ContentEventPayload
    ctx.hooks.on('content:after:create', (payload) => {
      console.info('Created:', payload.collection, payload.id)
    })
  },
})

2. Install

Called once when the plugin is first installed (admin action).

definePlugin({
  // ...
  async install(ctx) {
    console.info('My plugin installed!')
  },
})

3. Activate

Called when the plugin is activated via the admin UI.

definePlugin({
  // ...
  async activate(ctx) {
    console.info('My plugin activated!')
  },
})

4. Deactivate

Called when the plugin is deactivated.

definePlugin({
  // ...
  async deactivate(ctx) {
    // clean up resources
  },
})

5. Uninstall

Called when the plugin is removed.

definePlugin({
  // ...
  async uninstall(ctx) {
    console.info('My plugin uninstalled!')
  },
})

Hook System

The hook system provides event-driven extensibility. Hooks use a typed facade — each event's payload is statically typed so TypeScript catches wrong field names at the call site.

Declaring Hook Subscriptions

Declare the required capability before subscribing. Subscriptions go in onBoot.

definePlugin({
  id: 'my-plugin',
  version: '1.0.0',
  capabilities: ['hooks.content:subscribe'],

  async onBoot(ctx) {
    // typed — payload is ContentEventPayload
    ctx.hooks.on('content:after:create', (payload) => {
      console.info('Created in', payload.collection, 'id:', payload.id)
    })

    // before-hook: mutate payload to transform the write
    ctx.hooks.on('content:before:update', (payload) => {
      payload.data.updatedAt = Date.now()
      return payload   // return updated payload to pass it down the chain
    })
  },
})

Catalog Events

Content lifecycle (requires hooks.content:subscribe)

EventWhen
content:readBefore a content item is returned
content:before:createBefore insert — handlers may mutate payload.data or throw to cancel
content:before:updateBefore update — same as above
content:before:deleteBefore delete
content:after:createAfter insert committed
content:after:updateAfter update committed
content:after:deleteAfter delete committed
content:after:publishAfter a document is published

Auth events (requires hooks.auth:subscribe)

EventWhen
auth:registration:completedUser completed self-registration
auth:password-reset:requestedPassword reset requested (carries resetToken — never expose this)
auth:password-reset:completedPassword reset confirmed
auth:magic-link:consumedMagic link sign-in completed
auth:otp:verifiedOTP code verified

Declarative Hooks (alternative to onBoot)

For simple static subscriptions, use the hooks object directly on definePlugin:

definePlugin({
  id: 'my-plugin',
  version: '1.0.0',
  capabilities: ['hooks.auth:subscribe'],

  hooks: {
    'auth:registration:completed': (payload) => {
      console.info('New user:', payload.user.email)
    },
  },
})

Declarative hooks are subscribed during the wire phase, before onBoot runs. Use onBoot for dynamic or conditional subscriptions.

Custom Events

Plugins can emit and subscribe to their own events using the raw hook system:

definePlugin({
  // ...
  async onBoot(ctx) {
    // subscribe to your own custom event
    ctx.raw.hooks.register('my-plugin:processed', async (data) => {
      console.info('Processed:', data)
      return data
    })
  },
})

Capabilities

Capabilities declare what platform services a plugin is allowed to use. Undeclared services throw SonicCapabilityError at access time rather than silently failing later.

Available Capabilities

Capability stringWhat it grantsAccess via
'email:send'Send transactional emailctx.cap.emailEmailService
'cache:read'Read from cachectx.cap.cache
'cache:write'Write to cachectx.cap.cache
'media:read'Read media records
'media:write'Upload/delete media
'http:fetch'Outbound HTTP requestsctx.cap.httpfetch
'cron:register'Register cron jobs
'admin:menu'Add admin sidebar entries
'hooks.content:subscribe'Subscribe to content eventsctx.hooks.on('content:*', ...)
'hooks.auth:subscribe'Subscribe to auth eventsctx.hooks.on('auth:*', ...)
'db:<tableName>'Scoped DB access for a specific table

Declaring and Using Capabilities

import { definePlugin } from '@sonicjs-cms/core'

export const myPlugin = definePlugin({
  id: 'my-plugin',
  version: '1.0.0',
  // declare every capability the plugin uses
  capabilities: ['email:send', 'hooks.content:subscribe'],

  async onBoot(ctx) {
    // ctx.cap.email is typed as EmailService (because 'email:send' is declared)
    await ctx.cap.email.send({
      to: 'user@example.com',
      subject: 'Hello',
      html: '<p>World</p>',
    })

    // ctx.cap.http is typed as fetch (would throw if 'http:fetch' not declared)
    // ctx.cap.cache is available if 'cache:read' or 'cache:write' is declared
  },
})

Cron Support

Plugins can register scheduled tasks via crons and onCronTick. The schedule runs on Cloudflare's cron triggers.

export const myPlugin = definePlugin({
  id: 'my-plugin',
  version: '1.0.0',
  capabilities: ['cron:register', 'email:send'],

  // declare one or more cron schedules
  crons: [
    { schedule: '0 9 * * 1', hookFamily: 'weekly-digest' },
    { schedule: '*/15 * * * *', hookFamily: 'sync-check' },
  ],

  async onCronTick(event, ctx) {
    if (event.hookFamily === 'weekly-digest') {
      // send weekly email report
      await ctx.cap.email.send({ to: 'team@example.com', subject: 'Weekly Digest', html: '...' })
    }
    if (event.hookFamily === 'sync-check') {
      // run periodic sync
    }
  },
})

You also need to declare the cron schedule in wrangler.toml:

[triggers]
crons = ["0 9 * * 1", "*/15 * * * *"]

Schema-Driven Settings

Declare a configSchema and SonicJS auto-generates an admin settings form at /admin/settings/plugins/<id>, parses form submissions, and persists values — no custom settings route needed.

import { definePlugin } from '@sonicjs-cms/core'
import type { ConfigSchema } from '@sonicjs-cms/core'

const schema: ConfigSchema = [
  { key: 'apiKey', type: 'string', label: 'API Key', required: true, secret: true },
  { key: 'webhookUrl', type: 'string', label: 'Webhook URL' },
  { key: 'maxRetries', type: 'number', label: 'Max Retries', default: 3 },
  { key: 'enabled', type: 'boolean', label: 'Enable notifications', default: true },
]

export const myPlugin = definePlugin({
  id: 'my-plugin',
  version: '1.0.0',
  configSchema: schema,

  async onBoot(ctx) {
    // settings are loaded from ctx.env or the plugins table
    const apiKey = (ctx.env?.MY_PLUGIN_API_KEY as string) ?? ''
  },
})

Document Types

Plugins store structured data by registering a document type in onBoot and writing to the documents table. Do not create new database tables.

Registering a Document Type

import { definePlugin } from '@sonicjs-cms/core'
import { z } from 'zod'

const itemSchema = z.object({
  title: z.string(),
  active: z.boolean().default(true),
})

export const myPlugin = definePlugin({
  id: 'my-plugin',
  name: 'My Plugin',
  version: '1.0.0',

  async onBoot(ctx) {
    // register document type — no new DB table needed
    await ctx.env.DB.prepare(`
      INSERT OR IGNORE INTO document_types (id, name, source, settings, tenant_id)
      VALUES (?, ?, 'system', '{}', 'default')
    `).bind('my-plugin-item', 'My Plugin Item').run()
  },

  register(app) {
    app.get('/api/my-plugin/items', async (c) => {
      // read via DocumentRepository (raw SQL, tenant-scoped)
      const result = await c.env.DB.prepare(`
        SELECT id, data FROM documents
        WHERE type_id = ? AND tenant_id = 'default' AND is_current_draft = 1
      `).bind('my-plugin-item').all()
      return c.json({ success: true, data: result.results })
    })
  },
})

All document reads and writes must be tenant-scoped (AND tenant_id = ?).


Admin Interface

Add pages and menu items to the admin interface using the declarative menu array.

definePlugin({
  id: 'my-plugin',
  name: 'My Plugin',
  version: '1.0.0',

  menu: [
    {
      label: 'My Plugin',
      path: '/admin/my-plugin',
      icon: 'puzzle-piece',
      order: 50,
    },
    {
      label: 'Settings',
      path: '/admin/my-plugin/settings',
      icon: 'cog',
      order: 51,
    },
  ],

  register(app) {
    app.get('/admin/my-plugin', async (c) => {
      // render admin page
      return c.html('<h1>My Plugin</h1>')
    })

    app.get('/admin/my-plugin/settings', async (c) => {
      return c.html('<h1>Settings</h1>')
    })
  },
})
FieldTypeDescription
labelstringDisplay label
pathstringURL path
iconstringHeroicon name or emoji
ordernumberSort order (lower = earlier)

The icon field accepts either an emoji, an SVG string, or a named Heroicon (e.g., credit-card, chart-bar, image, magnifying-glass). Named icons are automatically resolved to SVGs in the admin sidebar.


Plugin Context

onBoot and onCronTick receive a fully typed, capability-gated context. install, activate, deactivate, and uninstall receive an opaque context (avoid touching env there — it may not be available).

Context Interface (onBoot / onCronTick)

interface DefinedPluginContext {
  /** Typed hook facade — ctx.hooks.on('content:after:create', handler) */
  hooks: TypedHooks

  /**
   * Capability-gated services.
   * ctx.cap.email   → EmailService    (requires 'email:send' declared)
   * ctx.cap.cache   → cache service   (requires 'cache:read' or 'cache:write')
   * ctx.cap.http    → fetch           (requires 'http:fetch')
   * Accessing an undeclared capability throws SonicCapabilityError.
   */
  cap: CapabilityContext

  /** Cloudflare runtime bindings (DB, CACHE_KV, R2, etc.) */
  env?: Record<string, unknown>

  /** The raw boot/cron context (escape hatch for low-level access) */
  raw: PluginBootContext | CronContext
}

Using the Database

Access env.DB inside onBoot or inside route handlers (via c.env). Do not use env inside install/activate — it may not be present.

definePlugin({
  // ...
  async onBoot(ctx) {
    const db = ctx.env?.DB as D1Database
    const result = await db.prepare(
      "SELECT * FROM documents WHERE type_id = ? AND tenant_id = 'default'"
    ).bind('my-plugin-item').all()
  },
})

Using KV Storage

definePlugin({
  // ...
  async onBoot(ctx) {
    const kv = ctx.env?.CACHE_KV as KVNamespace
    await kv.put('my-plugin:config', JSON.stringify({ enabled: true }))
    const stored = await kv.get('my-plugin:config', 'json')
  },
})

Example Plugin Reference

The bundled example plugin is the canonical reference for SonicJS plugin development. Every new install includes it at src/plugins/example/.

Files:

  • index.ts — plugin definition with routes, menu, onBoot seeding, and settings schema
  • routes/api.ts — public Hono routes (/example, /example/:name, /example/moods)
  • routes/admin.ts — protected admin page at /admin/example
  • collections/moods.collection.tsCollectionConfig for mood entries

Key patterns it demonstrates:

  1. Why /example/* not /api/* — user plugins mount after the core /api/:collection catch-all, so custom routes must use a different prefix
  2. onBoot seeding — creates default moods documents if none exist; idempotent
  3. Settings schemaconfigSchema with Zod defines the Settings tab form
  4. Collection registrationmoodsCollection imported and passed to registerCollections() in src/index.ts

Read src/plugins/example/index.ts in your project for the full annotated source.


Best Practices

1. Plugin Naming

Use lowercase with hyphens: weather-forecast, user-analytics

2. Semantic Versioning

Follow semver: 1.0.0 (initial), 1.1.0 (feature), 1.1.1 (fix), 2.0.0 (breaking)

3. Provide Sensible Defaults via Zod

Use a configSchema (Zod) to declare and validate settings — do not rely on a plugins DB table.

import { z } from 'zod'

const configSchema = z.object({
  enabled: z.boolean().default(true),
  cacheEnabled: z.boolean().default(true),
  cacheTTL: z.number().default(3600),
})

4. Validate Inputs

import { z } from 'zod'

const inputSchema = z.object({
  email: z.string().email(),
  name: z.string().min(1).max(100)
})

// inside register(app):
app.post('/api/my-plugin/users', async (c) => {
  const body = await c.req.json()
  const validated = inputSchema.parse(body)
  return c.json({ success: true, data: validated })
})

5. Handle Errors Gracefully

app.get('/api/my-plugin/data', async (c) => {
  try {
    const data = await fetchData()
    return c.json({ success: true, data })
  } catch (error) {
    console.error('Failed to fetch data:', error)
    return c.json({ success: false, error: 'Failed to fetch data' }, 500)
  }
})

6. Clean Up Resources

definePlugin({
  // ...
  async deactivate(ctx) {
    if (refreshInterval) clearInterval(refreshInterval)
    cache.clear()
  },
})

Testing

Unit Testing

import { describe, it, expect } from 'vitest'
import { myPlugin } from '../index'

describe('My Plugin', () => {
  it('should have correct metadata', () => {
    expect(myPlugin.id).toBe('my-plugin')
    expect(myPlugin.version).toBe('1.0.0')
  })

  it('should declare menu items', () => {
    expect(myPlugin.menu).toBeDefined()
    expect(myPlugin.menu.length).toBeGreaterThan(0)
  })
})

Running Tests

npm run test

Troubleshooting

Plugin Not Loading

If your plugin isn't being loaded:

  1. Check registration - Ensure registerPlugins(app, [myPlugin], { hookSystem, env }) is called in src/index.ts
  2. Verify the export - Make sure your plugin is exported from its index.ts
  3. Check for errors - Look for validation errors in the console during startup
  4. Restart the dev server - Some changes require a full restart

TypeScript Errors

If you get type errors with definePlugin:

import { definePlugin } from '@sonicjs-cms/core'

// definePlugin infers types automatically
export const myPlugin = definePlugin({
  id: 'my-plugin',
  name: 'My Plugin',
  version: '1.0.0',
  // ...
})

Next Steps

Was this page helpful?