A "CRUD app" — Create, Read, Update, Delete — is the shape of almost every business tool you'll ever build. Onboarding, bookings, contacts, tickets, orders, RSVPs, classified ads. Domma CMS gives you everything to build one without writing JavaScript, but the trick is understanding why the pieces compose the way they do. That's what this tutorial is for.
Before we build anything, the picture you want in your head:
A CRUD app is just somewhere to put data, a way for people to give it to you, buttons that change it once it's there, and a page that shows it back. Domma CMS gives each of those a name:
These are deliberately separate. A single Collection might be written to by three different Forms (one for staff, one for the public, one for an import job) and read by four different Pages (a dashboard, a public listing, a per-user "my entries" view, a printable report). Keeping them separate is what lets the same data drive multiple experiences.
Open Collections → New collection. You'll give it a slug (the URL-safe name we use in shortcodes), a title, and a list of fields.
The slug matters more than you'd think. It's what every Form, Action, and shortcode
uses to refer to this collection — so pick a noun and stick with it. jobs,
applications, contacts, events. Don't pluralise inconsistently
and don't include the word "data" or "collection" in the slug — that's redundant.
Fields are where the design lives. Each field has a type, and the type isn't just for show — it drives every downstream behaviour:
text renders a text input on forms and is searchable in the Browsernumber renders a number input, gets a min/max range filter automaticallyselect with options becomes a dropdown both on forms AND in the filter railmultiselect becomes a checkbox group AND a filterable tag chip setdate renders a date picker AND gets a from/to range filterfile renders a file upload with mime/size validationreference stores a link to another collection's entry — auto-renders as a populated dropdownPick the right type at design time and you get the right form input, the right filter UI, and the
right validation, all for free. Pick text for everything and you have to recreate all
that yourself.
Tip: always include a status field as a select with values
like pending, reviewing, approved, rejected. This is what makes
state-machine transitions possible later. You don't need it until you do, but adding it later means
back-filling every existing record. Add it on day one.
You don't need code to list a Collection on a public page. Drop this in any Markdown page:
[collection slug="jobs" display="cards" columns="3"
title-field="title" sort="postedAt" order="desc"
fields="company,location,salary,type" /]
That single shortcode gives you a server-rendered, cached, SEO-friendly grid of cards. The server reads the collection at request time, filters it, sorts it, and produces static HTML — every visitor gets the same instant render. The page is cached per role, so a million visitors viewing a public page hit the cache and never touch the data store.
Five display modes are built in:
display="table" — sortable, paginated, with a search boxdisplay="cards" — visual grid (default 3 columns; configurable)display="list" — vertical stack with title + metadisplay="accordion" — expandable rows, title visible, body hidden until clickeddisplay="timeline" — chronological with dates and statusesAll five display the same data; you pick the right shape for the page. A directory might use
cards; an admin overview might use table; a history page might use
timeline.
Adding any of searchable, filterable, or sortable upgrades the
static list to a full Collection Browser:
[collection slug="jobs" display="cards" columns="3"
searchable
filterable="location,type,salary,tags"
sortable
page-size="9"
empty="No matching jobs." /]
Now the page has a search box, a filter rail down the left side, a sort dropdown, and pagination — all generated automatically from the schema you wrote in Step 1. The Browser is the moment your app starts to feel like an app rather than a brochure.
Why the filter rail is automatic: remember each field has a type? The Browser
reads those types and builds the appropriate control. location (text) becomes a
dropdown of distinct values harvested from the data. salary (number) becomes a
min/max range with a slider. tags (multiselect) becomes toggleable chips with counts.
You authored zero filter logic.
Open Forms → New form. The slug here is the form's identity —
what you embed on a page with [form name="..." /].
Form fields can mirror the collection fields exactly, or they can be a subset. You almost
always want a subset. Fields like status shouldn't appear on a public-facing
submission form — the form should set status to "pending" automatically. Fields like
internalNotes shouldn't be visible to the submitter at all. The form is your
audience-facing slice of the collection; design it for who's filling it in.
Wire the form to the collection in Actions → Collection: enable it and pick the target collection slug. Every submission becomes one new entry in that collection. Done.
When a signed-in user submits a form, the CMS automatically stamps the new entry with their user
id (in the entry's meta.createdBy) AND passes their identity into any actions the
form fires. That's how the "My applications" view later works without any extra config: you ask
for "entries created by the current user" and the platform already has that data.
Anonymous submissions still work — createdBy is null and the action receives an empty
user. So one form can serve both anonymous contact-us submissions AND authenticated apply-for-this-job
submissions; the difference shows up in what the action templates can resolve.
This is where most no-code platforms top out. An Action is a server-side button: visitors click it on a page, the server runs a sequence of steps against the entry they clicked on, and the page refreshes.
Steps include: updateField (set one field to a new value), deleteEntry
(remove the entry), createInCollection (write a related entry to another collection
— this is how "apply for this job" creates an application without losing the job), email
(send a notification using the entry's fields as template variables), and webhook
(POST to an external service).
Actions can declare a transition — "this action moves the entry's status from pending to reviewing". That single addition unlocks two huge things:
transitions to a [collection] block
and each row sprouts buttons for exactly the transitions legally available given that row's
current status AND the viewer's role. A candidate sees "Withdraw" on their pending application;
an admin sees "Move to reviewing"; a candidate viewing a rejected application sees nothing
to click. No client-side conditionals; the rules live entirely in the action definitions.
{
"slug": "withdraw-application",
"title": "Withdraw",
"collection": "applications",
"transition": { "field": "status", "from": ["submitted", "reviewing"], "to": "withdrawn" },
"access": { "roles": ["candidate"], "rowLevel": { "mode": "owner" } },
"steps": [
{ "type": "updateField", "config": { "field": "status", "value": "withdrawn" } }
]
}
Read that JSON like a sentence: "Anyone with the candidate role can withdraw their own
application as long as it's currently submitted or reviewing." The platform enforces every
clause: access.roles for the role check, rowLevel.mode: 'owner' so
candidates can only touch their own entries, transition.from for the status guard.
Together they're the whole authorisation policy for this one button.
Pages have a visibility frontmatter field that decides who can load the page at all.
It accepts a single role ("editor and everyone above") or an array of roles
("candidates OR employers"). Roles are hierarchical — higher-privilege roles inherit
access to lower-privilege gated pages without you adding them explicitly.
For per-user data within a page that mixed-role users share — like a "My applications"
block on a dashboard that both candidates and employers visit — use scope="mine":
[collection slug="applications" scope="mine"
display="cards" title-field="jobId"
fields="jobId,status,submittedAt"
empty="You haven't applied yet." /]
The page itself stays cached per role; the per-user block renders client-side via a small hydration
request that injects createdBy = current-user-id server-side (the client can never
tamper with which user's data they see). Anonymous visitors see a sign-in prompt where the block
would render.
For cross-collection scoping — "recruiter sees only applications for jobs they posted" —
use the reference row-access mode in your action's access.rowLevel. The
platform resolves the reference to check ownership on the target. Full row-access reference covers all three
modes (owner, field, reference) with worked examples.
Three field types deserve their own mention because they're where Domma stops looking like a CMS and starts looking like a development platform:
File fields (type: "file") render a native file picker that uploads
to /content/media/ with mime + size validation, then stores a reference object
{url, name, size, mime} on the entry. Image mimes auto-display as thumbnails on the
page; PDFs and docs become "download" links. The form switches to multipart submission
automatically the moment any file input has a selection — no extra config.
Reference fields (type: "reference") store the id of another
collection's entry. The form picker becomes a populated dropdown of the target collection's
entries (showing the displayField); the page display resolves to the readable label
everywhere; with a linkTemplate the label becomes a clickable link to the target's
detail page. Validation refuses to save an entry pointing at a non-existent target. Dangling
references (target deleted later) render as "id (missing)" instead of crashing the page.
Status fields with options + paired transition-bearing
actions = a workflow. We covered this in Step 4 but it deserves repeating: the combination of a
typed status field, a few actions with transition.from, and the transitions
attribute on a [collection] shortcode gives you a real state machine that's enforced
everywhere (UI, API, audit) with no JavaScript.
We touched on the Browser in Step 2. Here's the rest of what it does for free once you turn it
on with searchable filterable=... sortable:
exportable and a button generates a CSV of the
current filtered set./ focuses search, ←/→
pages.pagination="scroll" replaces the pager with
a sentinel that loads more on scroll.mode="server" makes every
change round-trip to the API; the storage adapter (Mongo or File) handles the query;
authoring stays identical.None of this requires any JavaScript on your part. You add attribute names to a shortcode.
A job board uses every primitive in this tutorial:
jobs (employer posts roles) and applications (candidates apply)applications has a reference field jobId pointing at jobsapplications has a file field resume for the candidate's PDF[collection slug="jobs"] on /jobs with the Browser turned onapplications + an action that emails HR/dashboard with visibility: [candidate, employer] and
two [collection scope="mine"] blocks (one per role)transitions and each row gets the right buttons
for its current statusaccess.rowLevel: { mode: 'reference', field: 'jobId', targetCollection: 'jobs' }
on their transition actions so they only ever see applications for jobs they postedThat's a complete recruitment platform built in pure JSON + Markdown. The full annotated build lives at docs/recruitment-recipe.md.
Reading is one thing; having a real working subsystem to poke at is another. The CMS ships with starter recipes that scaffold a complete Collection + Form + Actions in a single operation. Try one now:
Recipes are JSON files under server/services/recipes/ — copy one as a starting
point for your own. The scaffolding docs
cover the recipe format in full.
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.
plugins/my-plugin/ directory with all three required files.[plugins] Loaded N plugins: …, my-plugin in
the log.
GET /api/plugins/my-plugin/helloTip: use npm run dev during development —
the server
restarts automatically on file changes.
Every form in Domma CMS can trigger up to four things after a submission is stored:
These are configured per-form in the admin. Open any form in Forms and go to the Settings and Actions tabs.
Go to the Actions tab of your form and enable Send email on submit. Enter one or more comma-separated recipient addresses. This uses the SMTP settings configured in Site Settings → Email / SMTP.
Recipients: admin@example.com, team@example.com
Subject Prefix: [Contact Form]
Enable POST to webhook on submit and enter a URL. Domma will POST the following JSON body:
{
"form": "enquiries",
"data": {
"full_name": "Jane Smith",
"email": "jane@example.com",
"message": "Hello!"
}
}
Use this to integrate with Zapier, Make, Slack, or any HTTP endpoint.
Actions are reusable workflow steps defined in Actions. A single action can chain multiple steps: update a field, move an entry to another collection, send an email, call a webhook, or delete an entry.
To wire an Action to a form:
The Action runs server-side, after the entry is saved. If it fails (e.g. MongoDB is not configured), the submission is still stored — the action failure is non-fatal and logged as a warning.
After a successful submission the visitor sees one of two things:
Example: set Success Redirect URL to /thank-you and create a thank-you.md
page in the CMS with any content you like.
On every submission, the pipeline runs in this fixed order:
Steps 3–5 are non-fatal: a failure in any of them is logged as a warning but does not prevent the submission from being stored or the success response from being returned.