Features Apograph CMS on GitHub

What Apograph CMS is

A plugin host, a code-defined content model, and a Postgres database you own — what that means in practice.

Documents 0.5.2 Updated Edit this page Report a problem

On this page

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

CapabilityWhat you get
Content modellingCollections and singles, twelve field types, four relation cardinalities, generated Postgres tables
EditingA generated form per type, a structured rich-text editor, a media library, a ⌘K search palette
PublishingDraft and published states, required-to-publish validation, soft delete with restore, and optional approvals before a type may go live
Version historyEvery save snapshotted, a field-level diff between any two versions, restore and publish-from-version
LocalizationOne row per locale, shared and per-locale fields, translation groups, coverage reporting
DeliveryA REST API and a GraphQL API over the same data, guards and scopes
Access controlThree global roles, permission keys per route, workspaces, long-lived API tokens, per-type publication protection
AuditingAn append-only activity log written in the same transaction as the change it records
IntegrationOutgoing webhooks on entry changes — signed, retried from a queue, with a delivery log
AIAn 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 /api for the admin, /api/v1 for the public content API and its GraphQL adapter, and /reference for 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.