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.
Lifecycle
Section titled “Lifecycle”- Define runtime — the plugin calls
definePlugin()at module load time withhooks,providers,blocks, and lifecycle callbacks. Validation covers runtime behavior only. - Discover — the plugin engine globs
app/plugins/*/index.server.jsand siblingpackage.jsonfiles, verifiesapp/plugins/<slug>/matchesbermooda.slug, and merges package identity with the runtime export. - Register —
register(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. - Enable —
enable(pluginId)wires hook handlers onto the event bus, registers providers frommanifest.providers, and callsonEnable(ctx). IfonEnablethrows, hooks and providers are unwound andisEnabledstaysfalse. Admin toggles persist theenabledPluginsarray using full package ids viasetPluginEnabledState()and callenable()immediately; if live wiring fails, the Setting is rolled back. - Disable —
disable(pluginId)callsonDisable(ctx), unregisters providers, and removes hook handlers. Admin toggles usesetPluginEnabledState()to updateenabledPluginsand calldisable()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.
Package.json contract
Section titled “Package.json contract”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" }] }}Field reference
Section titled “Field reference”| 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:
idis the fullpackage.jsonname, such as@bermooda/plugin-meilisearch.bermooda.slugmust 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 equalbermooda.slug. enabledPluginsand plugin data namespaces store full package ids.bermooda.engineis checked against the shop rootpackage.jsonversion. The CLI rejects install and update when incompatible; at runtime, discovery logs and soft-skips incompatible plugins instead of failing startup.adminRoutesandstorefrontRoutesdo not belong in package metadata. Route presence is discovered fromadmin/routesandstorefront/routesfiles.
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 entry
Section titled “Runtime entry”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.
Imports
Section titled “Imports”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.
Next steps
Section titled “Next steps”- Hooks and ctx —
definePlugin, event catalog, before-hooks, and lifecyclectx - Blocks and routes — storefront slots, admin slots, routes, and
PluginData - Build a plugin — folder layout, i18n, enable state, and npm dependencies