Contents
- Domma CMS User Manual
- 1. Using the CMS
- 2. Tutorials
- 3. Components
- 4. API Reference
- 5. Tools
Building a CRUD App
Updated by Darryl Waterhouse on 29 September 2026 · 12 min read
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.
The mental model
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:
- Collection - the data store. Think "spreadsheet": rows are records, columns are fields with types.
- Form - how new records get added. It writes to a Collection on submit.
- Action - a server-side button that changes existing records. "Approve", "Withdraw", "Send invoice".
- Page - a Markdown page with shortcodes that render forms and lists from your collections.
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.
Why not a database? Collections store as flat JSON files on disk by default - no database to install, no SQL to learn, no migrations. When you outgrow that, move individual collections to MongoDB (a Pro feature) from the collection's Storage tab without changing any of your pages or forms. The move keeps every entry's id, data and dates, so references and ownership still point at the right rows. The compatibility layer is the point.
Step 1 - Create the Collection (your data store)
Open Data > 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 (
string) renders a text input on forms and is searchable in the Browser numberrenders a number input, gets a min/max range filter automatically- Dropdown (
select) with options becomes a dropdown both on forms AND in the filter rail daterenders a date picker AND gets a from/to range filtermultiselectbecomes a checkbox group AND a filterable tag chip setfilerenders a file upload with mime/size validationreferencestores a link to another collection's entry - auto-renders as a populated dropdown
The collection editor's Fields tab offers text, email, phone, number, textarea, dropdown, radio buttons, checkbox, checkbox group, date, time, URL and hidden. multiselect, file, reference and a few others are supported everywhere else but are set by editing the collection's content/collections/<slug>/schema.json.
Pick 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.
Step 2 - Read the data (display it on a page)
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.
Eight 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 statusesdisplay="carousel"- one slide per entrydisplay="listgroup"- a compact list-group row per entrydisplay="block"- each entry through a reusable block template you design
Visitors can right-click any of them to filter, sort, copy or export (export follows the collection's export setting). All eight 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.
Make it interactive - flip on the Browser
Adding any of searchable, filterable, sortable or paginate 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.
Step 3 - Create new records (the Form)
Open Data > 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.
A form stores each submission in the collection with the same slug as the form, and creates that collection from its fields if it does not exist yet. The other way round, every collection you make in the admin gets a matching form of its own - so for jobs you usually open the jobs form and trim it, rather than starting a new one. Every submission becomes one new entry in that collection. Done.
Identity capture (the small thing that makes everything work)
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.
Step 4 - Change records over time (the Action)
This is where most no-code platforms top out. Actions are defined in Data > Actions and are stored in MongoDB, so they need a MongoDB connection (a Pro feature); everything before this step works without one. 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), moveToCollection and notify (a notification in the admin bell).
The transition is the magic word
Actions can declare a transition - "this action moves the entry's status from pending to reviewing". That single addition unlocks two huge things:
- Server-side guard. Try to run "withdraw" on an already-rejected application and the server refuses with HTTP 409. The illegal move can't happen, even if someone copies and edits the URL.
- Per-row UI. Add
transitionsto an interactive[collection]block (one withsearchable,sortable,filterableorpaginate;scope="mine"too) 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 (or a more senior one) can withdraw their own application as long as it's currently submitted or reviewing." When an action lists several roles, any one of them is enough. A role name the site does not have admits nobody but the level-0 role, and an action that names no roles is for admins (levels 0 and 1). 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.
Step 5 - Who sees what (Visibility + scope)
Pages have a visibility setting (in the page editor, stored in the frontmatter) that decides who can load the page at all. It accepts a single role (candidate means "candidate and everyone more senior") or an array of roles ("candidates OR employers"). Roles are a ladder by level - more senior roles inherit access to less senior gated pages without you adding them explicitly. To keep a page to exactly one role, prefix it with =: =candidate admits only people who hold the candidate role (and the level-0 role), not employers or admins. The same rules apply to menu items. Roles are managed in System > Roles; a site starts with super-admin, admin and user, and plugins can add their own.
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 in the browser 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. Make the block interactive (add paginate, searchable, sortable or filterable) and it becomes a Collection Browser of the viewer's own rows, so transitions works on it too; the server refuses a transition on anyone else's row.
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. The row-access section of docs/configuration.md (in the Domma CMS package) covers all three modes (owner, field, reference) with worked examples. Saved Views apply the same row-level rules per viewer.
Step 6 - Files, references, and "feels like a real app"
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. Imports are checked the same way: an imported entry pointing at a missing target is skipped and reported.
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 an interactive [collection] shortcode gives you a real state machine that's enforced everywhere (UI, API, audit) with no JavaScript.
Step 7 - The Browser tour (advanced reading UI)
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 (or just paginate):
- Faceted filter counts - every filter chip shows the count of entries that would match if that option were toggled with current other filters. Standard ecommerce-style faceted browsing.
- Empty state with "Clear filters" - when the user narrows to zero results, one click resets and gets them un-stuck.
- Saved searches - dropdown in the header lets users name and recall their favourite filter combinations.
- CSV export - add
exportableand a button generates a CSV of the current filtered set. - Mobile filter drawer - narrow viewports get a "Filters (n)" button that slides the rail in from the left as an overlay.
- URL state sync - every change writes to the URL query string, so deep-links and the browser back button work.
- Relevance sort during search - when the search box has a term, results re-order by match count with a 3x boost on matches in the title field.
- Keyboard shortcuts -
/focuses search,←/→pages. - Infinite scroll -
pagination="scroll"replaces the pager with a sentinel that loads more on scroll. - Server-mode for huge datasets -
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.
Pulling it together - the worked example
A job board uses every primitive in this tutorial:
- Two collections:
jobs(employer posts roles) andapplications(candidates apply) applicationshas areferencefieldjobIdpointing atjobsapplicationshas afilefieldresumefor the candidate's PDFcandidateandemployerroles, added in System > Roles- A public
[collection slug="jobs"]on/jobswith the Browser turned on - An apply form that writes to
applications+ an action that emails HR (actions need MongoDB) - A dashboard at
/dashboardwithvisibility: [candidate, employer]and two[collection scope="mine"]blocks (one per role) - Transition actions: start-review, invite-to-interview, make-offer, reject, withdraw - each with its own role + state guards
- The candidate dashboard's
scope="mine"block addspaginate transitionsand each row gets the right buttons for its current status - Recruiters get
access.rowLevel: { mode: 'reference', field: 'jobId', targetCollection: 'jobs' }on their transition actions so they only ever see applications for jobs they posted
That's a complete recruitment platform built in pure JSON + Markdown. The full annotated build is docs/recruitment-recipe.md in the Domma CMS package.
Skip ahead - scaffold a working CRUD system in one click
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. docs/scaffolding.md in the Domma CMS package covers the recipe format in full. Applying a recipe whose collection, form, action or menu already exists is refused (HTTP 409) rather than overwriting it.
Next: Writing a Plugin
On this page
- The mental model
- Step 1 - Create the Collection (your data store)
- Step 2 - Read the data (display it on a page)
- Make it interactive - flip on the Browser
- Step 3 - Create new records (the Form)
- Identity capture (the small thing that makes everything work)
- Step 4 - Change records over time (the Action)
- The transition is the magic word
- Step 5 - Who sees what (Visibility + scope)
- Step 6 - Files, references, and "feels like a real app"
- Step 7 - The Browser tour (advanced reading UI)
- Pulling it together - the worked example
- Skip ahead - scaffold a working CRUD system in one click