Deep Dives14 min read

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.

SonicJS Team

Layered isometric visualization of the SonicJS plugin architecture with stacked request lifecycle layers, floating plugin module blocks, and glowing hook event pulses propagating through the stack

Inside the SonicJS Plugin Architecture: A Deep Dive

Updated for v3: The PluginBuilder class and PluginManager were removed. v3 uses definePlugin() + registerPlugins().

TL;DR β€” SonicJS v3 plugins are TypeScript objects created with definePlugin(). registerPlugins() wires them into the app, resolves dependencies into a load order, registers their routes, middleware, services, models, and hooks, then walks them through lifecycle methods (register + onBoot). Settings are defined via a Zod configSchema inside definePlugin(), with hooks fired through a priority-ordered, scoped event bus.

Key Stats:

  • ~28 plugins ship with or alongside every SonicJS v3 install (auth, media, analytics, OAuth, OTP, Stripe, Lexical editor, and more)
  • Lexical is the default rich-text editor in v3
  • 25+ named hook events across app, request, auth, content, media, plugin, admin, and DB lifecycles
  • Key lifecycle entry points per plugin: register(app) and onBoot(ctx)

If you've used SonicJS plugins from the admin UI, you've seen the friendly side: a toggle, a settings page, and the feature lights up. This post is the unfriendly tour β€” the side most CMS docs gloss over. We're opening the hood on how @sonicjs-cms/core actually loads, registers, validates, and fires plugins on every request.

This is not a tutorial on writing your first plugin (for that, see the user guide on extending SonicJS). This is a deep dive into the internals: the definePlugin() factory, the registerPlugins() orchestrator, the priority-ordered hook bus, Zod-based configSchema settings, and the deliberate route-mounting order in createSonicJSApp() that makes the whole system tick.

If you're evaluating SonicJS for a project where you'll need to extend the CMS in serious ways β€” custom auth, custom routes, custom admin pages, third-party integrations β€” this is the architecture you're betting on.

Why a Plugin Architecture Matters at the Edge

A traditional CMS plugin system is generally a side concern. WordPress can afford to load every active plugin's PHP on every request because it runs on long-lived processes with shared opcode caches. The cost is amortized.

SonicJS runs on Cloudflare Workers, where every isolate is short-lived, every byte of bundle size has a budget, and every middleware function runs synchronously in the request hot path. That changes the design constraints:

  • Plugins must be tree-shakeable. Disabled plugins shouldn't bloat the worker bundle.
  • Registration must be deterministic. Routes and hooks fire in a strict order, and that order has to be predictable across deployments.
  • State must be portable. A plugin can't rely on in-memory caches that survive between requests β€” settings have to come from D1 or KV.
  • Failure must be isolated. A buggy plugin should never bring down core CMS functionality.

The SonicJS plugin architecture is built around those four constraints. Let's see how.

The Plugin Interface

Every SonicJS v3 plugin is created with the definePlugin() factory exported from @sonicjs-cms/core. The full shape:

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

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

  // Zod schema for typed, validated plugin settings
  configSchema: z.object({
    apiKey: z.string().optional(),
    webhookUrl: z.string().url().optional(),
  }),

  // Mount routes and middleware onto the Hono app
  register(app) {
    app.get('/my-plugin/status', (c) => c.json({ ok: true }))
  },

  // Admin sidebar entries
  menu: [
    { label: 'My Plugin', path: '/admin/my-plugin', icon: 'puzzle' },
  ],

  // Runs once at bootstrap β€” seed data, connect services
  async onBoot(ctx) {
    ctx.logger.info('my-plugin booted')
  },
})

There's no class hierarchy, no decorators, no DI container β€” just a typed object literal passed to definePlugin(). That's intentional: plain objects survive bundling well, are easy to test, and don't require any framework metadata.

The ctx object passed to onBoot is the gateway to everything: D1 (ctx.db), KV (ctx.kv), R2 (ctx.r2), the auth/content/media services, and a per-plugin logger. Plugins never reach into globals β€” every dependency is injected.

Three Tiers of Plugins

SonicJS v3 organizes plugins into three tiers, each living at a different point in the source tree:

TierLocationExamplesLoaded
Corepackages/core/src/plugins/core-plugins/auth, media, analytics, oauth-providers, otp-login, stripe, security-audit, user-profiles, lexical-editorAlways
Availablepackages/core/src/plugins/available/magic-link-auth, email-templates, easy-mdxOn demand
UserProject-level src/plugins/Anything you writeWhen configured

v3 ships approximately 28 plugins in total. Lexical is the default rich-text editor (replacing Quill/TinyMCE from v2). All plugins are created with definePlugin() and registered via registerPlugins([...]) in the app entry point.

Available plugins are still shipped with the package, but they're only wired up when registerPlugins() in src/index.ts includes them. Magic-link auth is the canonical example β€” add it to the registerPlugins array and its routes activate.

User plugins are everything you write. They call definePlugin() and are passed to the same registerPlugins() call. There is no second-class API for "plugins you didn't write" β€” your code gets the full extension surface.

Plugin Registration with registerPlugins()

In v3, registerPlugins() replaces the old PluginManager class. The flow looks like this:

                  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                  β”‚   createSonicJSApp   β”‚
                  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                             β”‚ calls
                  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                  β”‚   registerPlugins    β”‚
                  β”‚  (plugin[])          β”‚
                  β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”‚
                  β”‚  β”‚ HookSystem    β”‚   β”‚
                  β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β”‚
                  β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”‚
                  β”‚  β”‚ PluginRegistryβ”‚   β”‚
                  β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β”‚
                  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                             β”‚ register(app) β†’ onBoot(ctx)
                  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                  β”‚      Plugin          β”‚
                  β”‚  routes / hooks /    β”‚
                  β”‚  menu / configSchema β”‚
                  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

registerPlugins(plugins, app, ctx) does the heavy lifting:

  1. Resolve dependencies β€” topological sort over each plugin's dependencies array.
  2. Call register(app) β€” each plugin mounts its routes and middleware onto the Hono app.
  3. Inject menu items β€” plugin.menu entries are merged into the admin sidebar.
  4. Run onBoot(ctx) β€” per-plugin setup with access to D1, KV, R2, and services.
  5. Fire the global plugin:boot hook so other plugins can react.

If any step throws, the error is logged against that plugin but does not break the host app. Other plugins continue to load.

Lifecycle: register β†’ onBoot

In v3 the lifecycle is streamlined to two primary entry points:

MethodWhen it firesWhat it should do
register(app)At app construction timeMount routes, middleware, admin pages
onBoot(ctx)After bootstrap (DB ready)Seed settings, connect services, run migrations

register is synchronous and runs before any request is served β€” it's the right place to call app.route() and app.use(). onBoot is async and runs after the D1/KV/R2 bindings are available β€” it's the right place to seed defaults or warm caches.

The OTP login plugin is a good example: its register mounts POST /auth/otp/request and POST /auth/otp/verify, while onBoot validates that the required D1 table exists. Settings defaults (codeLength: 6, codeExpiryMinutes: 10, maxAttempts: 3, rateLimitPerHour: 5) are declared in the plugin's configSchema default values rather than imperatively seeded.

Plugin Discovery and Load Order

In v3, plugins are discovered one way:

  1. Static imports β€” plugins are imported directly by src/index.ts at build time and passed to registerPlugins([...]). There is no runtime scanning, no manifest registry, and no plugins database table.

Once registered, the order in which they activate matters. registerPlugins does a topological sort over each plugin's dependencies array. A plugin that depends on core-auth is guaranteed to boot after auth is up. If a circular dependency is detected, the registry throws and the offending plugins are skipped.

Route Mounting Order

Hono routes match in registration order, and SonicJS leans on that hard. In app.ts, the mounting sequence is deliberate:

// Plugin routes - Security Audit (MUST be registered BEFORE admin/plugins
// to avoid route conflict)
if (securityAuditPlugin.routes && securityAuditPlugin.routes.length > 0) {
  for (const route of securityAuditPlugin.routes) {
    app.route(route.path, route.handler as any)
  }
}

// Plugin routes - AI Search (MUST be registered BEFORE admin/plugins to
// avoid route conflict)
// Register AI Search routes first so they take precedence over the generic
// /:id handler
if (aiSearchPlugin.routes && aiSearchPlugin.routes.length > 0) {
  for (const route of aiSearchPlugin.routes) {
    app.route(route.path, route.handler)
  }
}

// ... OAuth, OTP, User Profiles, Analytics, Stripe ...

app.route('/admin/plugins', adminPluginRoutes)

The reason for that pattern: the /admin/plugins route includes a generic /:id handler that matches any plugin slug. If oauthProvidersPlugin mounted after /admin/plugins, the generic handler would steal its requests. By mounting plugin-owned admin routes first, each plugin's specific paths win, and only requests for actual plugin slugs fall through to the generic handler.

This is enforced by convention rather than the type system β€” which is why every plugin route block in app.ts carries an explanatory comment. If you're adding new core plugin routes, that order is part of the contract.

The Hook System

Hooks are SonicJS's answer to "I want to do X every time Y happens." The HookSystemImpl class exposes three primary methods:

hooks.register('user:login', async (data, ctx) => {
  await sendWelcomeEmail(data.userId)
  return data
}, 10)

await hooks.execute('user:login', { userId: '123' })

A few details that aren't obvious from the surface:

  • Priority is ascending. Lower numbers run first. The default is 10. If you want to run before authentication-side-effects, register at priority 1.
  • Handlers receive and return data. The execution loop threads each handler's return value into the next handler. This makes hooks transformative by default, not just observational.
  • Cancellation is opt-in. ctx.cancel() halts the chain. Subsequent handlers don't run.
  • Recursion is detected. The system tracks an executing set to prevent infinite loops if a hook handler triggers the same hook.
  • Errors are isolated. A handler that throws is logged but doesn't abort the chain β€” unless its message contains "CRITICAL", which short-circuits everything.

The standard hook names live in the exported HOOKS constant:

CategoryHooks
App lifecycleapp:init, app:ready, app:shutdown
Request lifecyclerequest:start, request:end, request:error
Authauth:login, auth:logout, auth:register, user:login, user:logout
Contentcontent:create, content:update, content:delete, content:publish, content:save
Mediamedia:upload, media:delete, media:transform
Pluginplugin:install, plugin:uninstall, plugin:activate, plugin:deactivate
Adminadmin:menu:render, admin:page:render
Databasedb:migrate, db:seed

That's 25 named events spread across eight categories. Plugins can also define custom hook names β€” there's no enforced enum at runtime β€” but sticking to the standard names ensures interoperability.

For an outbound integration story (Slack, Zapier, custom HTTP destinations), see SonicJS webhooks. The hooks reference lists every event with its data shape.

Scoped Hook Systems

Each plugin gets its own ScopedHookSystem, which is a thin wrapper around the global hook bus that tracks which hooks were registered by which plugin. When the plugin uninstalls, unregisterAll() rips out every hook the plugin registered in one call.

This solves a class of bug that haunts long-lived CMS installations: the "ghost handler." When a plugin is removed without unregistering its event listeners, the listeners stay in the global bus and silently process events forever β€” sometimes for a removed feature, sometimes for a feature that's been replaced. The scoped system makes that impossible.

Settings Persistence: configSchema + SettingsService

SonicJS v3 uses two complementary approaches for plugin settings. There is no plugins database table or 006_plugin_system.sql migration.

1. Zod configSchema in definePlugin

Each plugin declares its typed settings schema directly in definePlugin(). Defaults live here too β€” no imperative seeding required:

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

const plugin = definePlugin({
  id: 'otp-login',
  name: 'OTP Login',
  version: '1.0.0',

  configSchema: z.object({
    codeLength: z.number().default(6),
    codeExpiryMinutes: z.number().default(10),
    maxAttempts: z.number().default(3),
    rateLimitPerHour: z.number().default(5),
  }),

  register(app) { /* ... */ },
  async onBoot(ctx) { /* ... */ },
})

The admin settings UI reads this schema to auto-generate forms. Values are validated by Zod at write time.

2. The relational settings table (via SettingsService)

For system-wide configuration shared across plugins (site name, security settings), SonicJS uses a separate settings table with a category/key/value structure. The SettingsService wraps it:

const settings = new SettingsService(db)
const otpConfig = await settings.getCategorySettings('otp-login')
// { codeLength: 6, codeExpiryMinutes: 10, maxAttempts: 3, ... }

await settings.setSetting('otp-login', 'codeLength', 8)

Most plugins rely on configSchema defaults for their own options and read from SettingsService only when they need a global value (e.g., site name for outbound emails).

Plugin Introspection in the Admin UI

In v3, the admin UI reflects plugin state from the in-memory registry populated at bootstrap β€” not from database tables. registerPlugins() builds a runtime list of active plugins (id, name, version, menu items, configSchema) that the /admin/plugins route reads directly.

Hook registrations and mounted routes are also visible in the runtime registry, enabling the admin UI to show "what's wired up" without any database queries. Audit logging of plugin lifecycle events (boot errors, config changes) goes through the standard application logger rather than a dedicated plugin_activity_log table.

Real Plugin Example: OAuth Providers

The OAuth providers plugin (packages/core/src/plugins/core-plugins/oauth-providers/) is one of the more complex core plugins, and a good case study. It:

  • Defines a BUILT_IN_PROVIDERS map (GitHub, Google) with their authorize/token/userinfo endpoints
  • Exports an OAuthService class that handles the full OAuth 2.0 dance
  • Mounts admin routes at /admin/plugins/oauth for client-credential management
  • Mounts public auth routes at /auth/oauth/:provider and /auth/oauth/:provider/callback
  • Stores per-provider client IDs and secrets via its configSchema settings (validated by Zod)
  • Fires the auth:login hook after a successful OAuth callback so other plugins (like security audit) can react

You can dig into the full setup in the OAuth plugin docs and the code examples plugin which uses a similar pattern for content management.

What Plugins Can't Do

A few hard limits worth being explicit about:

  • No global mutation. Plugins can't patch core middleware or rewrite the request pipeline. They mount in the slots the registry exposes.
  • No synchronous I/O. Every hook handler is async. Cloudflare Workers don't allow blocking calls.
  • No process state. A plugin can't store data in module-level variables and expect it to survive between requests. Use D1, KV, or R2.
  • No cross-plugin imports of internals. Plugins talk to each other through the hook bus and the public service interfaces, not by importing each other's modules.

These constraints are how SonicJS keeps the plugin surface honest. Following the coding standards for SonicJS plugins ensures your code passes review and won't be broken by the next core release.

Putting It All Together: A Request's Journey

Here's what happens when a real request β€” say POST /auth/login β€” flows through a SonicJS app with three plugins enabled (auth, security-audit, otp-login):

1. Request hits Cloudflare Worker
2. metricsMiddleware()        ─ records start time
3. bootstrapMiddleware()      ─ ensures plugins are initialized
4. Custom beforeAuth chain    ─ user-supplied middleware
5. securityHeadersMiddleware  ─ sets CSP, HSTS, etc.
6. csrfProtection             ─ validates CSRF token
7. /auth/* matched            ─ routes into authRoutes
8. securityAuditMiddleware    ─ logs login attempt
9. authRoutes handler         ─ verifies password
10. hooks.execute('auth:login', { userId })
    β”œβ”€ otp-login handler      ─ priority 10 (no-op for password login)
    β”œβ”€ security-audit handler ─ priority 20 (writes audit row)
    └─ user plugin handler    ─ priority 30 (sends webhook)
11. Session issued via Better Auth ─ JWT API token also available via POST /auth/login
12. Response written
13. metricsMiddleware()       ─ records duration

Every plugin contributes at exactly the moment its registration says it should. No plugin needs to know about the others β€” they all subscribe to the same hook bus, and the manager handles ordering and isolation.

Key Takeaways

  • SonicJS v3 plugins are created with definePlugin() and registered via registerPlugins() β€” no PluginBuilder, no PluginManager class.
  • Three tiers (core, available, user) share a single registration pipeline and lifecycle.
  • Two lifecycle entry points: register(app) (sync, route/middleware mounting) and onBoot(ctx) (async, DB-ready setup).
  • Settings are declared as a Zod configSchema inside definePlugin() β€” no plugins.settings JSON column or 006_plugin_system.sql migration.
  • The HookSystemImpl is a priority-ordered, scoped, transformative event bus with built-in recursion detection.
  • Route mounting order is part of the contract β€” plugin routes register before generic /admin/plugins/:id to avoid being shadowed.
  • The admin UI reads plugin state from the in-memory registry, not from dedicated plugin database tables.
  • Auth sessions are issued via Better Auth; a JWT API token is also available via POST /auth/login.

This architecture is what lets SonicJS scale from "tiny edge CMS" to "full SaaS backend" without forking. Every commercial feature in the platform β€” Stripe billing, OAuth, audit logs, magic links β€” is built on the exact same plugin contract that's open to your team. There's no internal API that core uses but your code can't.

If you're building on SonicJS and have questions about the plugin internals, drop into the SonicJS Discord or open a discussion on the GitHub repository. And if you're ready to ship your own plugin, start with the authentication overview and the code examples plugin as a working reference.

#architecture#plugins#internals#typescript#hono#cloudflare

Share this article

Related Articles

Isometric visualization of a modular plugin system with extension blocks plugging into a central CMS hub via glowing data lines
Guides

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.