Architecture (3 Mandatory Files)
A plugin is a self-contained directory inside plugins/ consisting of exactly three required files. Each file has a distinct responsibility: the manifest declares metadata, the server file registers backend logic, and the admin file provides the frontend integration.
plugins/
└── my-plugin/
├── plugin.json # Manifest — name, slug, description, version, author
├── server.js # Backend: Fastify routes, hooks
└── admin.js # Frontend: admin views, sidebar entries
Enable or disable any plugin by editing config/plugins.json. The CMS will not load a plugin unless its entry is present and enabled is set to true.
[
{ "slug": "my-plugin", "enabled": true }
]
Disabling a plugin ("enabled": false) is non-destructive — the directory and all its data remain intact.
Plugin Manifest (plugin.json)
The manifest is the single source of truth for plugin identity and capabilities. The CMS reads it before loading any code, so mismatches here will prevent the plugin from mounting.
{
"name": "My Plugin",
"slug": "my-plugin",
"version": "1.0.0",
"description": "Does something useful",
"author": "Your Name",
"minCmsVersion": "0.5.0",
"permissions": ["pages:read", "collections:read"],
"public": {
"injectHead": "head.html",
"injectBody": "body.html"
},
"adminNav": {
"text": "My Plugin",
"icon": "package",
"view": "my-plugin"
}
}
Key fields:
slug— Must match the directory name and the entry inconfig/plugins.json. Lowercase, hyphenated.minCmsVersion— The CMS will refuse to load the plugin if the running version is older. Use semantic versioning.permissions— Declares which CMS data scopes the plugin accesses. Shown to the user at install time.public.injectHead/public.injectBody— Paths to HTML snippets for server-side injection into public pages (see Section 6).adminNav— Registers a sidebar entry. Theviewkey must match a view name exported byadmin.js.
Backend Plugin (server.js)
The server.js file exports a standard Fastify plugin function. The CMS passes an opts object containing a cms property which exposes the full CMS service layer — content, collections, media, users, and hooks.
export default async function(fastify, opts) {
const { cms } = opts; // Access to CMS service layer
// Register Fastify routes
fastify.get('/api/my-plugin/data', async (request, reply) => {
// Access CMS services:
const pages = await cms.content.getAll();
return { pages };
});
fastify.post('/api/my-plugin/action', {
preHandler: [fastify.authenticate] // Require auth
}, async (request, reply) => {
// ...
});
}
Notes:
- Plugin routes are automatically scoped and registered by the CMS — you do not need to call
fastify.register()yourself. - Use
fastify.authenticateas apreHandlerto restrict routes to logged-in admin users. - Access any CMS service via
cms:cms.content,cms.collections,cms.media,cms.users,cms.hooks. - The function must be
asyncand the default export must be the plugin function directly (not an object).
Admin Integration (admin.js)
The admin.js file exports a view definition that the Domma CMS admin SPA registers at runtime. Views are mounted inside the admin shell — the full Domma API is available in onMount.
export default {
views: {
'my-plugin': {
title: 'My Plugin',
template: `
<div class="container py-6">
<h1>My Plugin</h1>
<!-- Domma components work here -->
</div>
`,
onMount($el) {
H.get('/api/my-plugin/data').then(data => {
T.create('#my-table', { data: data.pages, columns: [...] });
});
}
}
},
sidebar: [
{ text: 'My Plugin', icon: 'package', view: 'my-plugin' }
]
}
Available Domma APIs inside onMount:
H.get(),H.post(),H.put(),H.delete()— HTTP requests to your plugin's backend routesT.create()— DataTable with sorting, filtering, paginationE.modal(),E.toast(),E.confirm()— UI componentsF.create()— Blueprint-driven form generationM.create()— Reactive models for state management$el— Domma-wrapped container element for scoped DOM operations
The view key ('my-plugin') must match the adminNav.view value declared in plugin.json.
Hook System
Plugins can intercept CMS lifecycle events by registering hook callbacks via cms.hooks.on(). Hooks allow plugins to modify content before rendering, react to form submissions, register custom shortcodes, and sanitise HTML output — all without patching core CMS code.
// In server.js
cms.hooks.on('page:before-render', (page) => {
// Modify page before rendering
page.content += '\n<!-- rendered by my-plugin -->';
return page;
});
cms.hooks.on('form:submission', async (submission) => {
// React to form submissions
await sendToExternalCRM(submission);
});
// Register a custom shortcode
cms.hooks.registerShortcode('my-widget', (attrs) => {
return `<div class="my-widget">${attrs.content}</div>`;
});
// Sanitize hook (modify HTML after rendering)
cms.hooks.on('html:sanitize', (html) => html.replace(/foo/g, 'bar'));
Available events:
| Event | Triggered when | Payload |
|---|---|---|
page:before-render |
A public page is about to be rendered | Page object (mutable) |
page:after-render |
A public page has finished rendering | Rendered HTML string |
form:submission |
A CMS form receives a submission | Submission data object |
media:upload |
A file is uploaded via the media manager | File metadata object |
collection:entry-save |
A collection entry is created or updated | Entry object with collection name |
user:login |
An admin user logs in | User object (read-only) |
user:register |
A new admin user is registered | New user object |
html:sanitize |
After HTML rendering, before output | HTML string (return modified string) |
Hooks that return a value replace the original payload. Async hooks are awaited by the CMS before proceeding. Multiple plugins may register the same event — they run in registration order.
Public Injection (Head/Body Slots)
Plugins can inject HTML into every public page without touching the theme templates. Two injection slots are available, both declared in plugin.json under the public key and resolved relative to the plugin directory.
injectHead — inserted in <head>
Use for CSS, meta tags, preload hints, fonts, and anything that must be parsed before the page renders.
<!-- head.html -->
<link rel="stylesheet" href="/plugins/my-plugin/style.css">
<meta name="my-plugin-version" content="1.0.0">
injectBody — inserted before </body>
Use for JavaScript, analytics snippets, chat widgets, or anything that should load after page content.
<!-- body.html -->
<script src="/plugins/my-plugin/tracker.js"></script>
Injections are only active when the plugin is enabled. Disabling the plugin immediately removes all injections from public pages without requiring a restart.
Bundled Plugins
Domma CMS ships with a set of ready-to-enable plugins. They are installed but disabled by default — add the relevant entry to config/plugins.json to activate any of them.
Adds Domma's full visual effects library to public pages — breathe, reveal, scribe, scramble, counter, ripple, and more. Configure which effects are active and their default options via the plugin settings view in admin.
Simple self-hosted analytics: page views, unique visitor counts, top pages, referrers, and device breakdown. All data stays on your server — no third-party tracking. A dashboard view is added to the admin sidebar.
Visual theme customisation tool accessible from the admin toolbar. Live preview of CSS variable overrides, theme selection, and one-click export. Changes can be saved as a custom theme file or applied directly to the site config.
GDPR-compliant cookie consent banner with configurable consent categories (necessary, analytics, marketing), customisable copy, and a link to your privacy policy. Consent state is stored in localStorage and respected across sessions.
Injects a configurable back-to-top button into all public pages. Appears after the user scrolls past a configurable threshold. Smooth scroll behaviour, position, and appearance are all configurable via plugin.json.
Enable any bundled plugin by adding its slug to config/plugins.json:
[
{ "slug": "analytics", "enabled": true },
{ "slug": "cookie-consent", "enabled": true },
{ "slug": "back-to-top", "enabled": true }
]
Building a Plugin (Step-by-Step)
Follow these steps to build and register a minimal working plugin — a simple hit counter with an admin view and an increment button.
-
Create the directory
mkdir plugins/my-counter -
Write
plugin.json— name, slug, version, description{ "name": "My Counter", "slug": "my-counter", "version": "1.0.0", "description": "A simple hit counter", "author": "Your Name", "adminNav": { "text": "Counter", "icon": "hash", "view": "my-counter" } } -
Write
server.js— registerGET /api/my-counter/countreturning a stored countexport default async function(fastify, opts) { const { cms } = opts; let count = 0; fastify.get('/api/my-counter/count', async () => ({ count })); fastify.post('/api/my-counter/increment', { preHandler: [fastify.authenticate] }, async () => { count += 1; return { count }; }); } -
Write
admin.js— view with a counter display and increment button using Domma UIexport default { views: { 'my-counter': { title: 'Counter', template: ` <div class="container py-6"> <h1 class="mb-4">Hit Counter</h1> <p class="text-xl mb-4">Count: <strong id="count-display">...</strong></p> <button id="increment-btn" class="btn btn-primary">Increment</button> </div> `, onMount($el) { H.get('/api/my-counter/count').then(data => { $el.find('#count-display').text(data.count); }); $el.find('#increment-btn').on('click', () => { H.post('/api/my-counter/increment').then(data => { $el.find('#count-display').text(data.count); E.toast('Counter incremented', { type: 'success' }); }); }); } } }, sidebar: [ { text: 'Counter', icon: 'hash', view: 'my-counter' } ] } -
Register the plugin — add to
config/plugins.json[ { "slug": "my-counter", "enabled": true } ] -
Start the server
npm run dev - View in admin — the Counter sidebar entry appears automatically. Navigate to it to see the counter and increment button.
-
Public injection (optional) — create
head.htmland/orbody.htmlin the plugin directory and declare them under thepublickey inplugin.jsonto inject content into every public page.