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.


  1. Create the plugins/my-plugin/ directory with all three required files.
  2. Restart the server — you should see [plugins] Loaded N plugins: …, my-plugin in the log.
  3. Go to the Plugins page and enable your plugin.
  4. Restart the server again to register the routes.
  5. 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 →