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.

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)andonBoot(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:
| Tier | Location | Examples | Loaded |
|---|---|---|---|
| Core | packages/core/src/plugins/core-plugins/ | auth, media, analytics, oauth-providers, otp-login, stripe, security-audit, user-profiles, lexical-editor | Always |
| Available | packages/core/src/plugins/available/ | magic-link-auth, email-templates, easy-mdx | On demand |
| User | Project-level src/plugins/ | Anything you write | When 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:
- Resolve dependencies β topological sort over each plugin's
dependenciesarray. - Call
register(app)β each plugin mounts its routes and middleware onto the Hono app. - Inject menu items β
plugin.menuentries are merged into the admin sidebar. - Run
onBoot(ctx)β per-plugin setup with access to D1, KV, R2, and services. - Fire the global
plugin:boothook 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:
| Method | When it fires | What it should do |
|---|---|---|
register(app) | At app construction time | Mount 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:
- Static imports β plugins are imported directly by
src/index.tsat build time and passed toregisterPlugins([...]). There is no runtime scanning, no manifest registry, and nopluginsdatabase 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
executingset 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:
| Category | Hooks |
|---|---|
| App lifecycle | app:init, app:ready, app:shutdown |
| Request lifecycle | request:start, request:end, request:error |
| Auth | auth:login, auth:logout, auth:register, user:login, user:logout |
| Content | content:create, content:update, content:delete, content:publish, content:save |
| Media | media:upload, media:delete, media:transform |
| Plugin | plugin:install, plugin:uninstall, plugin:activate, plugin:deactivate |
| Admin | admin:menu:render, admin:page:render |
| Database | db: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_PROVIDERSmap (GitHub, Google) with their authorize/token/userinfo endpoints - Exports an
OAuthServiceclass that handles the full OAuth 2.0 dance - Mounts admin routes at
/admin/plugins/oauthfor client-credential management - Mounts public auth routes at
/auth/oauth/:providerand/auth/oauth/:provider/callback - Stores per-provider client IDs and secrets via its
configSchemasettings (validated by Zod) - Fires the
auth:loginhook 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 viaregisterPlugins()β noPluginBuilder, noPluginManagerclass. - Three tiers (core, available, user) share a single registration pipeline and lifecycle.
- Two lifecycle entry points:
register(app)(sync, route/middleware mounting) andonBoot(ctx)(async, DB-ready setup). - Settings are declared as a Zod
configSchemainsidedefinePlugin()β noplugins.settingsJSON column or006_plugin_system.sqlmigration. - The
HookSystemImplis 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/:idto 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.
Related Articles

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.

Why Edge-First CMS is the Future of Content Management
Discover why edge-first content management systems like SonicJS are revolutionizing how we build and deliver digital experiences with unprecedented speed and reliability.

NestJS vs SonicJS vs Hono: Backend Framework Comparison 2026
Compare NestJS, SonicJS, and Hono frameworks. Performance benchmarks, architecture differences, and when to choose each for your next backend project.