Content types defined in code, one Postgres table each. The plugin holds
them in a registry, serves the schema to the admin, validates every write, and
serves the public content API at /api/v1/….
Install and register
npm install @orthacms/content-server @orthacms/content-admin// apps/server/src/plugins.ts
import { join } from 'node:path';
import { ContentPlugin, ContentViewsPlugin } from '@orthacms/content-server';
// Keep it in a variable: the GraphQL adapter and ContentViewsPlugin take the plugin itself.
const content = ContentPlugin({
types: contentTypes,
migrations: {
dir: () => join(process.cwd(), 'migrations'),
table: '__drizzle_migrations_content'
}
});
// in buildPlugins(): …, content, ContentViewsPlugin({ content }), …// apps/admin/src/plugins.ts
import { ContentPlugin } from '@orthacms/content-admin';
ContentPlugin();ContentPlugin({ types: [] })is valid: no tables, generic routes, boots.- A fresh app passes the generated manifest’s
contentTypes— empty until the first type — with themigrationsdescriptor already in place. - No environment variables. The content model is code — reviewed, diffed and deployed like code. In development the schema builder can write that code for you.
ContentViewsPlugin({ content })ships the saved-views tables. See content views.
Define a type
// apps/server/src/content/collections/article.ts
import { collection, field } from '@orthacms/content-server/define';
import { author } from './author';
import { tag } from './tag';
export const article = collection('article', {
label: 'Articles',
publishable: true,
i18n: true,
groups: {
seo: { label: 'SEO', description: 'Search and sharing.', collapsed: true }
},
fields: {
title: field.text({ required: true, localized: true, maxLength: 200 }),
slug: field.text({ required: true, admin: { widget: 'slug', width: 'half' } }),
publishedOn: field.date({ admin: { width: 'half' } }),
author: field.relation({ to: () => author, required: true, onDelete: 'restrict' }),
tags: field.relation({ to: () => tag, many: true }),
metaTitle: field.text({ admin: { group: 'seo' } })
}
});- Import the DSL from
@orthacms/content-server/define, not the main entry — drizzle-kit cannot bundle the decorated controllers. collection()for many records,single()for a page there is one of.- Field builders:
text,richtext,number,money,boolean,date,datetime,select,multiselect,json,relation,relationInverse,media.
Form layout hints (since 0.7.0):
| Key | Effect |
|---|---|
groups: { key: { label, description?, collapsed? } } | Collapsible form sections on the type |
admin.group: 'key' | Draw the field in that section. Unknown key fails at define time |
admin.width: 'half' | Half of the two-column form grid. Default is 'full' |
admin.row: 'key' | Put up to two fields sharing the key on one line |
See content types and field types for the full vocabulary.
Migrations belong to your app
ContentPlugin ships no migrations for your types. Your tables — one
content_<name> per type, plus a join table per many-relation — are generated
into your app’s migrations/:
npx orthacms generate --name=add-article # drizzle-kit against your drizzle.config.ts
npx orthacms migrate # applies yours and every plugin'sWhat it serves
| Surface | Auth | Used by |
|---|---|---|
/api/content-schema, /api/content/:typeName… | Session + X-Workspace-Id | The admin’s Content Library |
/api/v1/content-types, /api/v1/content/:typeName… | Authorization: Bearer API token | Your site, apps and scripts |
Both are limited to the content types the workspace was granted — its own, and since 0.7.0 those it takes from shared workspaces. Tokens are managed by the API tokens plugin.
curl 'https://cms.example.com/api/v1/content/article?locale=en&fields=title,slug&pageSize=10' \
-H 'Authorization: Bearer orthacms_…'
# → { "items": [ … ], "total": 42, "page": 1, "pageSize": 10 } # full-access token; a create is always a draft
curl -X POST https://cms.example.com/api/v1/content/article \
-H 'Authorization: Bearer orthacms_…' \
-H 'Content-Type: application/json' \
-d '{"values":{"title":"Autumn catalogue","slug":"autumn-catalogue","author":"5ac9e8f2-1d4b-4c7e-9a36-2f8b0d1e7c54"},"relations":{"tags":{"link":["b3e1c7d9-5a2f-4e86-9d10-7c4f2a8b6e31"]}}}'
curl -X POST https://cms.example.com/api/v1/content/article/<id>/publish \
-H 'Authorization: Bearer orthacms_…' const res = await fetch(
'https://cms.example.com/api/v1/content/article/' + id + '?relations=preview&relationFields=author',
{ headers: { Authorization: 'Bearer ' + process.env.ORTHACMS_TOKEN } }
);
if (res.status === 404) return null; // unknown, draft-only, deleted or hidden
const entry = await res.json(); // { id, status, publishedAt, locale, values, relations } - Reads serve published entries.
status=draft|anyneedscontent:update, which only a full-access token has. - Since 0.7.0, an edited published entry (Modified) keeps serving its
published version — values, links and
status: "published"— until the next publish.status=anyreturns the working copy. - A public
PATCHis partial: omitted keys are left alone,nullclears.
Details: reading, writing, filtering, expanding, errors. Same API over GraphQL.
Insights
The admin half fills most of the Insights page. Since
0.13.0: on Overview the Entries, Published and Drafts tiles, Publishing
velocity and By type; on Content Gone quiet and Waiting to go
live; on Team the punchcard, in the reader’s time zone. Two lines under
Needs attention — Waiting to go live and Gone quiet — each open
their records, oldest first, from
GET /api/insights/content/unshipped/records and
GET /api/insights/content/stale/records?age= (content:read), with a link
per type to the records table.
Comparing revisions in your own plugin
Since 0.13.0 @orthacms/content-admin exports what History uses to compare two
versions, so a plugin showing “what changed” draws it the same way —
protection’s review panel is the first user:
useRevisionDetail(typeName, id, number) reads one version,
diffRevision(schema, current, revision) returns a RevisionFieldDiff per
field, and fieldLabel, formatRevisionValue, RelationRefList and
MediaRefList render them. diffRevision mirrors the server’s
diffSnapshots, so a count the server sends and the rows drawn here agree.
Agent tools
Over MCP, sixteen generic tools that take typeName as an
argument:
| Tools | Do |
|---|---|
content_types_list, content_type_get | Discover the model |
content_list, content_get, content_relations, content_media, content_translations | Read |
content_create, content_update, content_publish, content_unpublish, content_delete | Write |
content_bulk_save, content_bulk_publish, content_bulk_unpublish, content_bulk_delete | Batch |
The copilot gets its own set — admin_content_types, admin_content_search,
admin_content_get, the revision reads, and content_propose_create,
content_propose_update, content_propose_bulk_save. Those save drafts through
the same entry writer and can never publish.