Skip to content

Plugins

Plugins extend bermooda without modifying core code. Each plugin is a self-contained directory under app/plugins/<slug>/ where the folder name matches package.json bermooda.slug. Identity lives in package.json; runtime behavior lives in index.server.js.

The registered plugin id is always the full package name from package.json name — for example @bermooda/plugin-meilisearch. URLs use bermooda.slug — for example /admin/plugins/meilisearch/* and /apps/meilisearch/*. The slug must be lowercase hyphenated.

All plugin infrastructure lives in app/core/plugins/index.server.js.

  1. Define runtime — the plugin calls definePlugin() at module load time with hooks, providers, blocks, and lifecycle callbacks. Validation covers runtime behavior only.
  2. Discover — the plugin engine globs app/plugins/*/index.server.js and sibling package.json files, verifies app/plugins/<slug>/ matches bermooda.slug, and merges package identity with the runtime export.
  3. Registerregister(manifest) adds the merged plugin manifest to the in-memory registry by full package id and indexes it by slug for URL dispatch. Registration does not enable the plugin.
  4. Enableenable(pluginId) wires hook handlers onto the event bus, registers providers from manifest.providers, and calls onEnable(ctx). If onEnable throws, hooks and providers are unwound and isEnabled stays false. Admin toggles persist the enabledPlugins array using full package ids via setPluginEnabledState() and call enable() immediately; if live wiring fails, the Setting is rolled back.
  5. Disabledisable(pluginId) calls onDisable(ctx), unregisters providers, and removes hook handlers. Admin toggles use setPluginEnabledState() to update enabledPlugins and call disable() immediately.

Toggling a plugin in admin updates the persisted enabledPlugins array for startup and calls enable() or disable() immediately so hooks, providers, and lifecycle callbacks are wired live without waiting for a restart.

Every plugin has a package.json for identity and display metadata:

{
"name": "@bermooda/my-plugin",
"version": "1.0.0",
"description": "Adds a custom integration.",
"private": true,
"bermooda": {
"title": "My Plugin",
"slug": "my-plugin",
"engine": ">=1.0.0",
"settings": [{ "key": "apiKey", "label": "API Key", "type": "password" }]
}
}
Runtime field Source Required Description
id name yes Full package name, including scope. This is the registry and persistence id.
version version yes Plugin version. Semver recommended.
description description no Short description shown in admin.
title bermooda.title yes Human-readable display title shown in admin.
slug bermooda.slug yes Lowercase hyphenated URL and folder key.
engine bermooda.engine yes Semver range of compatible bermooda app versions (for example >=1.0.0).
settings bermooda.settings no Package-driven admin settings schema.

Rules:

  • id is the full package.json name, such as @bermooda/plugin-meilisearch.
  • bermooda.slug must match ^[a-z0-9]+(?:-[a-z0-9]+)*$.
  • URLs use slug, not id: /admin/plugins/<slug>/* and /apps/<slug>/*.
  • Bundled folders use app/plugins/<slug>/. The folder name must equal bermooda.slug.
  • enabledPlugins and plugin data namespaces store full package ids.
  • bermooda.engine is checked against the shop root package.json version. The CLI rejects install and update when incompatible; at runtime, discovery logs and soft-skips incompatible plugins instead of failing startup.
  • adminRoutes and storefrontRoutes do not belong in package metadata. Route presence is discovered from admin/routes and storefront/routes files.

Setting field types: text, select, toggle, and password. Password values are encrypted at rest with AES-256-GCM (key derived from BETTER_AUTH_SECRET) and redacted when loaded for admin/API display. Leaving a password field blank on save keeps the existing value. Providers should read secrets with getPluginSettingSecret(pluginId, key) from #/core/plugins/settings.server.

Runtime behavior is declared in index.server.js. definePlugin() validates runtime only; it does not accept or validate identity fields.

import { defineHooks, definePlugin } from '#/core/plugins/index.server';
export const pluginManifest = definePlugin({
hooks: defineHooks({
'order.created': handleOrderCreated,
}),
providers: {
// optional provider specs
},
blocks: {
// optional storefront/admin slot components
},
onEnable: async (ctx) => {
// optional startup work
},
onDisable: async (ctx) => {
// optional cleanup work
},
});
export default pluginManifest;

Discovery merges package.json identity with this runtime export before registration. Export pluginManifest as both a named export and the default export.

See Hooks and ctx for definePlugin, defineHooks, providers, and the ctx object passed to onEnable / onDisable.

Inside a plugin package (app/plugins/<slug>/):

  • Import sibling plugin modules with relative paths (for example ./provider/index.server, ../data/index.server, ./package.json).
  • Import core app modules with the #/… alias (for example #/core/plugins/index.server, #/utils/logger.server, #/core/plugins/data/index.server).
  • Outside plugins, the core app and routes continue to load plugins via #/plugins/<slug>/….

Oxlint enforces the sibling-import rule with no-restricted-imports on app/plugins/**. Direct #/libs/prisma* imports inside app/plugins/** are also blocked — prefer domain APIs in app/core/* and namespaced plugin data storage.

  • Hooks and ctxdefinePlugin, event catalog, before-hooks, and lifecycle ctx
  • Blocks and routes — storefront slots, admin slots, routes, and PluginData
  • Build a plugin — folder layout, i18n, enable state, and npm dependencies