Contents
- Domma CMS User Manual
- 1. Using the CMS
- 2. Tutorials
- 3. Components
- 4. API Reference
- 5. Tools
Writing a Plugin
Updated by Darryl Waterhouse on 29 September 2026 · 6 min read
Plugins live in the plugins/ directory. Each plugin is a self-contained folder with three required files and optional admin/, public/ and docs/ subdirectories.
The quickest start is New plugin in the banner of System > Plugins (the Marketplace screen). It copies the built-in template into plugins/<slug>/ with a server entry, config defaults, a wired admin view and a CLAUDE.md, switched on. Restart the server to activate it. The rest of this page explains what those files do.
Directory structure
plugins/
my-plugin/
plugin.json - manifest (required)
plugin.js - Fastify plugin (required)
config.js - settings defaults (required)
admin/
views/
index.js - admin view (optional)
templates/
index.html - view template (optional)
css/
index.css - admin styles (optional)
public/
inject-head.html - injected into <head> on every page (optional)
inject-body.html - injected before </body> on every page (optional)
docs/
guide.md - user guide under Documentation > Plugins (optional)
data/ - plugin data store (optional, never served)
Only admin/ and public/ are served over HTTP; plugin.js, config.js and data/ never are.
1. plugin.json - the manifest
All fields below are required. Missing any will cause the plugin to be skipped on startup with a warning in the server log. name must match the folder name.
{
"name": "my-plugin",
"displayName": "My Plugin",
"version": "1.0.0",
"description": "A short description shown in the Marketplace.",
"author": "Your Name",
"date": "2026-03-01",
"icon": "star"
}
Common optional fields:
| Field | Type | Description |
|---|---|---|
inject.head | string | Path (relative to plugin root) to an HTML snippet injected into <head>. |
inject.headLate | string | A snippet injected at the end of <head>, after the site stylesheets - for CSS that must win. |
inject.bodyEnd | string | Path to an HTML snippet injected before </body>. |
admin.sidebar | array | Sidebar items to add to the admin panel. |
admin.routes | array | SPA routes to register in the admin router. |
admin.views | object | View modules to dynamically import into the admin SPA. |
admin.css | array | Admin stylesheets loaded with the plugin's views. |
permissions | array | Permission resources the plugin guards its routes with. They appear in the role editor; grant gives existing roles them once. |
requires | array | Built-in Tools (contacts, notes, todo, analytics, seo) or plugins this one cannot work without. It is not loaded while one is off. |
uses | object | {"contacts": "Invite a contact group"} - Tools or plugins it works without but uses when they are on. The text is what the admin is told they lose. |
supersedes | array | Plugins (or built-in Tools) this one replaces outright, such as a Pro edition of a free plugin. |
settingsSchema | array | Labels, types and help for the Marketplace Configure form. |
minCmsVersion | string | The oldest Domma CMS the plugin works on. Install and update are refused on an older CMS. |
2. plugin.js - the Fastify plugin
This is the server-side entry point. It must export a default async function that Fastify will call with (fastify, options).
The CMS hands you auth middleware in options.auth (authenticate, requireAdmin, requirePermission, requireRole, requireVisibility), extension points in options.hooks (shortcodes, transforms, sidebar items, notifications, registerComponent and more) and the merged settings in options.settings. Always take them from there rather than importing the middleware directly.
import { getPluginSettings, savePluginState } from '../../server/services/plugins.js';
export default async function myPlugin(fastify, options) {
const { authenticate, requireAdmin } = options.auth;
// Public endpoint - no auth needed
fastify.get('/hello', async () => {
return { message: 'Hello from my plugin!' };
});
// Admin-only endpoint (role levels 0 and 1)
fastify.get('/settings', { preHandler: [authenticate, requireAdmin] }, async () => {
return getPluginSettings('my-plugin');
});
fastify.put('/settings', { preHandler: [authenticate, requireAdmin] }, async (request) => {
savePluginState('my-plugin', { settings: request.body });
return { ok: true };
});
}
// Optional: runs once each time the plugin is switched on, not at install.
export async function onEnable({ fastify, services, hooks }) {
// Create collections, pages or forms here if needed.
}
// Optional: runs when it is switched off, and before an uninstall (uninstall: true).
export async function onDisable({ fastify, services, hooks, uninstall }) {
}
Routes are registered under the prefix /api/plugins/{name} automatically - write '/hello', not the full path. The prefix is always locked to your plugin's directory name.
For your own screens, prefer requirePermission('my-plugin', 'read') with a permission declared in plugin.json over requireAdmin: the site owner can then give it to any role in System > Roles.
3. config.js - settings defaults
Export a plain object of default settings. These are merged with any overrides stored in config/plugins.json when getPluginSettings() is called. The site owner changes them with Configure on the plugin in the Marketplace.
export default {
greeting: 'Hello, world!',
enableFeature: true,
maxItems: 10
};
config.js is only loaded for enabled plugins. Side-effect code here will not run for disabled plugins.
4. Admin views (optional)
To add a page to the admin panel, declare the route and view in plugin.json:
"admin": {
"sidebar": [
{
"id": "my-plugin",
"text": "My Plugin",
"icon": "star",
"url": "#/plugins/my-plugin",
"section": "#/plugins/my-plugin"
}
],
"routes": [
{
"path": "/plugins/my-plugin",
"view": "plugin-my-plugin",
"title": "My Plugin - Domma CMS"
}
],
"views": {
"plugin-my-plugin": {
"entry": "my-plugin/admin/views/index.js",
"exportName": "myPluginView"
}
}
}
The view file follows the standard Domma view pattern - a templateUrl and an onMount($container) function. Call your API with H.get / H.post, which add the sign-in header and refresh the token for you - do not hand-build a Bearer header with fetch():
// admin/views/index.js
export const myPluginView = {
templateUrl: '/plugins/my-plugin/admin/templates/index.html',
async onMount($container) {
const res = await H.get('/api/plugins/my-plugin/settings');
const settings = res.data ?? res;
$container.find('#greeting').text(settings.greeting);
I.scan();
}
};
The template is a plain HTML fragment (no <html> wrapper) using Domma classes (card, btn, form-input). The admin already wraps every plugin view in a banner with the plugin's name, version and licence, so do not draw your own page heading:
<!-- admin/templates/index.html -->
<div class="card">
<div class="card-body">
<p id="greeting">Loading...</p>
</div>
</div>
Files under admin/ are fetched by the browser, so a hard refresh of the admin picks up a change without a restart.
5. Injection snippets (optional)
HTML snippets declared in inject.head, inject.headLate and inject.bodyEnd are read from the plugin's folder and inserted into every public page. Use this for analytics scripts, stylesheets, or widgets. They are read once at start-up.
<!-- public/inject-body.html -->
<script>
(function () {
// This runs on every public page
fetch('/api/plugins/my-plugin/hello')
.then(r => r.json())
.then(d => console.log(d.message));
})();
</script>
Snippet paths are validated - they must stay within the plugin's own directory. Paths containing .. are blocked.
6. User guide (optional)
Markdown files in the plugin's docs/ folder become pages under Documentation > Plugins while the plugin is loaded, shown only to users who can use the plugin. guide.md is the first page; further files become tabs. Start with ## sections, not a top-level heading, and write for site owners.
7. Switching it on and testing
- Create the
plugins/my-plugin/directory with all three required files (or use New plugin, which does this and switches it on). - Open System > Plugins. A plugin you made by hand is listed, switched off. Right-click it (or use its menu) and choose Switch on. Its
onEnableruns now. - The server restarts by itself to load the plugin's routes and screens, where a supervisor (the fleet manager or pm2) will bring it back and
restartOnPluginToggleis notfalseinconfig/server.json. Otherwise restart it yourself. The log then shows[plugins] Loaded N plugins: ..., my-plugin. - Verify your endpoint:
GET /api/plugins/my-plugin/hello
Tip: use npm run dev during development - the server restarts automatically on file changes. Server-side files (plugin.js, config.js, plugin.json, snippets) only apply after a restart; the in-admin code editor (View source on the plugin) has a Restart server button. For the full reference see docs/plugin-development.md in the Domma CMS package.
Next: Form Follow-Up