Ortha CMS is a self-hosted headless CMS: a React admin, a NestJS API and a PostgreSQL database. It ships as a lockstep set of @orthacms/* npm packages, MIT-licensed.
Two decisions shape everything else.
Content types are code
A content type is a function call in your repository, not rows in the CMS’s database:
export const article = collection('article', {
label: 'Articles',
publishable: true,
fields: {
title: field.text({ required: true, maxLength: 200 }),
body: field.richtext(),
author: field.relation({ to: () => author, required: true })
}
});That one declaration:
- becomes a real
content_articletable with atitlecolumn, - generates the editor’s form,
- defines the REST and GraphQL shapes,
- is reviewed, versioned and rolled back like any other code.
The trade: changing the model is a deployment. If editors must add fields without an engineer, this is the wrong tool. In development the admin’s Content Model page can write the file and its migration for you — the schema builder — but what it produces is still code to commit and deploy.
Capability lives in plugins
The host has no domain logic. It turns a list of plugins into an app:
// apps/server/src/plugins.ts
export function buildPlugins(config: OrthaCmsConfig): ServerPlugin[] {
return [
DatabasePlugin({ connectionString: config.database.url }),
IdentityPlugin(config.plugins.identity),
WorkspacesPlugin(),
ContentPlugin({ types: contentTypes }),
MediaServerPlugin({
provider: createLocalStorageProvider(config.plugins.media.storage),
config: config.plugins.media
})
// …
];
}Auth, content, media, localization, the audit log, mail, webhooks, the AI copilot and MCP are all plugins. Adding a capability means writing a plugin and adding a line — never editing the host.
What a client sees
curl 'https://cms.example.com/api/v1/content/article?pageSize=10' \
-H 'Authorization: Bearer orthacms_…' \
-H 'X-Workspace-Id: <workspace-id>' \
-H 'X-Reader-Tags: premium' # optional: which segments this reader belongs toThe same data is available over GraphQL (POST /api/v1/graphql) and MCP (POST /api/v1/mcp) with the same token. See the delivery API.
What comes in the box
| Capability | What you get |
|---|---|
| Content modelling | Collections and singles, twelve field types, four relation cardinalities, generated Postgres tables, collapsible form sections and a two-column form layout |
| Editing | A generated form per type, a structured rich-text editor, a media library, a ⌘K search palette |
| Publishing | Draft and published states, required-to-publish validation, soft delete with restore, optional approvals per type |
| Version history | Every save snapshotted, field-level diffs, restore and publish-from-version |
| Localization | One row per locale, shared and per-locale fields, translation groups, coverage reporting |
| Delivery | REST, optionally GraphQL, over the same data, guards and scopes |
| Access control | Three global roles (Admin, Contributor, Viewer), permission keys per route, workspaces, API tokens, per-type publication protection |
| Shared workspaces | A workspace can share its published records read-only; others link to them through explicit per-type grants |
| Reader access | Segments restrict which published entries a reader sees — sent as X-Reader-Tags — on REST, GraphQL and MCP |
| Content checks | Alarms flag entries at Error, Warning or Info without blocking a save or publish |
| Import and export | Records with relations, files and locales, as JSON, JSON Lines, ZIP or CSV |
| Auditing | An append-only activity log written in the same transaction as the change, plus an Insights dashboard |
| Integration | Signed, retried webhooks with a delivery log; invitation and reset mail over SMTP |
| AI | Ortha CMS AI in the admin, behind one model port with two adapters, and an MCP endpoint for external agents |
What is not in the box
No scheduled publishing
Something has to publish an entry. There is no scheduler and no worker process — the webhook sender, mail sender and alarms sweep are in-process intervals, not a job queue.
Also absent:
- Full-text search beyond
ILIKEover a type’s text columns; no vector search. - Real-time collaboration or websockets.
- Per-workspace roles — a role is global; membership only decides which workspaces a user reaches.
- Webhooks for media, account or workspace events — webhooks cover the entry lifecycle and a test ping, at-least-once and unordered.
The shape of a running system
apps/server— the NestJS API:/apifor the admin,/api/v1for the public content API, GraphQL and MCP,/referencefor OpenAPI.apps/admin— a static React bundle, served by the API process in a generated app (orthacms start).- PostgreSQL 16 — one database. Each plugin owns its tables and migrations.
No external queue, cache or search cluster. Outside services are only what you choose: object storage for media, an SMTP relay, a model backend.
Where to go next
- Evaluating? Read how it is put together.
- Want a running instance? Follow the quick start.
- Modelling content? Start at content types.
- Building a front end? Go to the delivery API.