Apograph CMS is a headless content management system you run yourself. It is a
React admin application, a NestJS API and a PostgreSQL database, published as
a lockstep set of npm packages under the @apograph scope and licensed MIT.
Two decisions shape everything else in this documentation, and it is worth meeting both before anything else.
Content types are code, not database rows
In most content management systems you build a content model by clicking through an admin screen. The result lives in the CMS’s own database, as rows describing your types, and your application discovers the shape of its content at runtime.
Apograph does the opposite. A content type is a function call in your repository:
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 declaration is the only description of an article anywhere. It generates a
real content_article table with a real title column, it generates the form
the editor fills in, and it is reviewed, versioned and rolled back like any
other code you write. There is no second copy in a database that can disagree
with it, and no migration between “what the CMS thinks an article is” and “what
your queries assume an article is”.
The trade is explicit: changing the content model is a deployment, not an afternoon in an admin panel. If your editors need to add a field without an engineer, this is the wrong tool.
Capability lives in plugins, not in a core
The second decision is that there is barely a core. The host is a small, generic thing that turns a list of plugins into a running application:
// apps/server/src/plugins.ts
export const plugins: ServerPlugin[] = [
DatabasePlugin({ url: process.env.DATABASE_URL }),
IdentityPlugin(),
WorkspacesPlugin(),
ContentPlugin({ types: contentTypes }),
MediaServerPlugin({ providers: [createLocalStorageProvider()] })
];Authentication is a plugin. Content is a plugin. Media, localization, the audit
log, the AI copilot and the Model Context Protocol endpoint are plugins. The
host owns no domain logic at all — it runs each plugin’s init hook, imports its
NestJS module, and applies the global /api prefix.
This is why “how do I add X to Apograph” almost always has the same answer: you write a plugin and add it to that array. You never edit the host.
What comes in the box
| Capability | What you get |
|---|---|
| Content modelling | Collections and singles, twelve field types, four relation cardinalities, generated Postgres tables |
| 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, and optional approvals before a type may go live |
| Version history | Every save snapshotted, a field-level diff between any two versions, restore and publish-from-version |
| Localization | One row per locale, shared and per-locale fields, translation groups, coverage reporting |
| Delivery | A REST API and a GraphQL API over the same data, guards and scopes |
| Access control | Three global roles, permission keys per route, workspaces, long-lived API tokens, per-type publication protection |
| Auditing | An append-only activity log written in the same transaction as the change it records |
| Integration | Outgoing webhooks on entry changes — signed, retried from a queue, with a delivery log |
| AI | An in-product copilot behind one model port with two shipped adapters, and an MCP endpoint for external agents |
What is not in the box
This section is not modesty. These are the gaps an evaluator finds on day three, and finding them here is cheaper than finding them after a migration.
No scheduled publishing
An entry is published when somebody or something publishes it. You cannot set a future date and walk away. There is no scheduler and no separate worker process: the webhook sender and the alarms sweep are plain in-process intervals, and neither is a job queue a future publish could be put on.
Also absent: full-text search beyond ILIKE over a type’s text columns, any
kind of embedding or vector search, real-time collaboration or websockets, and
per-workspace roles — a user’s role is global, and workspace membership only
decides which workspaces they can reach.
Webhooks exist, but they are narrower than the word suggests: they cover the entry lifecycle and a test ping, not media, account or workspace events, and delivery is at-least-once and unordered rather than a message bus.
The shape of a running system
A deployment is three processes and nothing else:
apps/server— the NestJS API. Serves/apifor the admin,/api/v1for the public content API and its GraphQL adapter, and/referencefor the generated OpenAPI document.apps/admin— a static React bundle. It talks to the API over the same origin and holds no secrets.- PostgreSQL 16 — one database. Each plugin owns its own tables and ships its own migrations; the host applies all of them.
There is no external queue, no cache server, no search cluster and no object store unless you configure S3 for media — the webhook delivery queue is a Postgres table, drained by the API process itself. That is a deliberately small operational surface, and it is most of the argument for running this yourself.
Where to go next
If you are evaluating Apograph, read how it is put together next — it is the shortest path to knowing whether the plugin model fits how you work.
If you have a running instance and want to model content, start at content types.
If you are pulling content into a front end, go straight to the delivery API.