Writing a Plugin
Tutorials
Plugins live in the plugins/ directory. Each plugin is a self-contained folder with
three required files and optional admin/ and public/ subdirectories.
plugins/
my-plugin/
plugin.json ← manifest (required)
plugin.js ← Fastify plugin (required)
config.js ← settings defaults (required)
admin/
views/
my-view.js ← admin SPA view (optional)
templates/
my-view.html ← view template (optional)
public/
inject-head.html ← injected into <head> on every page (optional)
inject-body.html ← injected before </body> on every page (optional)
data/ ← plugin data store (optional, not publicly served)
All fields below are required. Missing any will cause the plugin to be skipped on startup with a warning in the server log.
{
"name": "my-plugin",
"displayName": "My Plugin",
"version": "1.0.0",
"description": "A short description shown on the Plugins page.",
"author": "Your Name",
"date": "2026-03-01",
"icon": "star"
}
Optional fields:
| Field | Type | Description |
|---|---|---|
inject.head |
string | Path (relative to plugin root) to an HTML snippet injected into <head>. |
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. |
This is the server-side entry point. It must export a default async function that
Fastify will call with (fastify, options).
The CMS injects auth middleware through options.auth — always destructure from there
rather than importing 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
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 };
});
}
Routes are registered under the prefix /api/plugins/{name} automatically. You do not
set the prefix yourself — it is always locked to your plugin's directory name.
Export a plain object of default settings. These are merged with any user overrides stored in
config/plugins.json when getPluginSettings() is called.
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.
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/my-view.js",
"exportName": "myPluginView"
}
}
}
The view file follows the standard Domma view pattern — a templateUrl and an
onMount($container) function:
// admin/views/my-view.js
export const myPluginView = {
templateUrl: '/plugins/my-plugin/admin/templates/my-view.html',
async onMount($container) {
const res = await fetch('/api/plugins/my-plugin/settings', {
headers: { 'Authorization': 'Bearer ' + (S.get('auth_token') || '') }
});
const settings = await res.json();
$container.find('#greeting').text(settings.greeting);
Domma.icons.scan();
}
};
The template is a plain HTML fragment (no <html> wrapper). Use the same card and
form patterns as the rest of the admin panel:
<!-- admin/templates/my-view.html -->
<div class="view-header">
<h1><span data-icon="star"></span> My Plugin</h1>
</div>
<div class="card">
<div class="card-body">
<p id="greeting">Loading…</p>
</div>
</div>
HTML snippets declared in inject.head and inject.bodyEnd are read from
the plugin's public/ directory and inserted into every public page. Use this for
analytics scripts, stylesheets, or widgets.
<!-- 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.
- Create the
plugins/my-plugin/directory with all three required files. - Restart the server — you should see
[plugins] Loaded N plugins: …, my-pluginin the log. - Go to the Plugins page and enable your plugin.
- Restart the server again to register the routes.
- Verify your endpoint:
GET /api/plugins/my-plugin/hello
Tip: use npm run dev during development —
the server restarts automatically on file changes.
Next: Form Follow-Up →