Beta . Ortha CMS is in public beta. Every plan is free until it ends.
Features Ortha CMS on GitHub

Content plugin

Content types, entries, revisions and publishing — plus the API that serves them.

Documents 0.13.1 Updated Edit this page Report a problem

On this page

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 the migrations descriptor 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):

KeyEffect
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's

What it serves

SurfaceAuthUsed by
/api/content-schema, /api/content/:typeName…Session + X-Workspace-IdThe admin’s Content Library
/api/v1/content-types, /api/v1/content/:typeName…Authorization: Bearer API tokenYour 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.

Example language
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|any needs content: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=any returns the working copy.
  • A public PATCH is partial: omitted keys are left alone, null clears.

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:

ToolsDo
content_types_list, content_type_getDiscover the model
content_list, content_get, content_relations, content_media, content_translationsRead
content_create, content_update, content_publish, content_unpublish, content_deleteWrite
content_bulk_save, content_bulk_publish, content_bulk_unpublish, content_bulk_deleteBatch

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.