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

Content types

Content plugin @orthacms/content-server@orthacms/content-admin

Two kinds of content type, the flags that shape their storage, form sections, and the table each one generates.

Documents 0.13.1 Updated Edit this page Report a problem

On this page

A content type is code: call collection() or single(), register the result with ContentPlugin, generate a migration. The table, the admin form, the REST routes and the GraphQL type all follow from that one declaration.

You can write that file by hand or, in development, let the schema builder write it from the admin’s Content Model page. Either way it ends as the same file and a migration.

A complete example

// apps/server/src/content/collections/product.ts
import { collection, field, type AnyContentType } from '@orthacms/content-server/define';
import { category } from './category';

export const product = collection('product', {
    label: 'Products',
    description: 'Things we sell.',
    publishable: true, // draft → published workflow
    paranoid: true,    // delete = tombstone, restorable
    i18n: true,        // one row per locale
    groups: {
        pricing: { label: 'Pricing', description: 'What it costs.' },
        advanced: { label: 'Advanced', collapsed: true }
    },
    fields: {
        name: field.text({ required: true, localized: true, maxLength: 120 }),
        slug: field.text({
            required: true,
            pattern: '^[a-z0-9]+(?:-[a-z0-9]+)*$',
            admin: { widget: 'slug' }
        }),
        description: field.richtext({ localized: true }),
        price: field.money({ min: 0, admin: { group: 'pricing', row: 'price' } }),
        currency: field.select({
            options: ['EUR', 'USD', 'GBP'],
            admin: { group: 'pricing', row: 'price' }
        }),
        sku: field.text({ admin: { group: 'pricing', width: 'half' } }),
        category: field.relation({ to: (): AnyContentType => category }),
        cover: field.media({ accept: { kinds: ['image'] } }),
        metadata: field.json({ admin: { group: 'advanced' } })
    }
});

What that produces:

SurfaceResult
Tablecontent_product — envelope columns plus one column per field
AdminA “Products” list and an entry form with two sections, Pricing and Advanced
RESTGET /api/v1/content/product, GET /api/v1/content/product/:id, …
GraphQLproduct(id:), products(...), createProduct, publishProduct, …
SchemaGET /api/v1/content-types/product

Read the schema back over the public API:

Example language
curl https://cms.example.com/api/v1/content-types/product \
-H 'Authorization: Bearer orthacms_…' \
-H 'X-Workspace-Id: 7d0c…'
{
  "name": "product",
  "kind": "collection",
  "label": "Products",
  "description": "Things we sell.",
  "publishable": true,
  "paranoid": true,
  "i18n": true,
  "groups": [
      { "key": "pricing", "label": "Pricing", "description": "What it costs.", "collapsed": false },
      { "key": "advanced", "label": "Advanced", "collapsed": true }
  ],
  "fields": [
      { "name": "name", "type": "text", "required": true, "localized": true,
        "validation": { "maxLength": 120 }, "admin": {} },
      { "name": "price", "type": "money", "required": false,
        "validation": { "min": 0, "integer": true }, "admin": { "group": "pricing", "row": "price" } }
  ]
}
query Products {
  contentTypes { name kind publishable paranoid i18n }
  products(locale: "en", pageSize: 10, sort: "-publishedAt") {
      total
      items { id name slug price currency }
  }
}

X-Workspace-Id is optional when the token covers exactly one workspace.

Collections and singles

collection() — many entries (articles, authors). single() — one entry (a home page, site settings). single() also requires path, the route the page renders at, starting with /.

import { single, field } from '@orthacms/content-server/define';

export const site_settings = single('site_settings', {
    label: 'Site settings',
    path: '/settings',
    fields: {
        siteName: field.text({ required: true }),
        supportEmail: field.text()
    }
});
  • Same table shape as a collection. Nothing in the database enforces one row.
  • REST serves a single through the list route — take items[0].
  • GraphQL gives a single one root field, siteSettings, and no list field.

Options

OptionTypeWhat it does
fieldsobjectThe field map. Keys become column names, snake_cased. Required, non-empty.
labelstringName in the admin. Defaults to the type name.
descriptionstringShown in the workspace wizard and type pickers.
publishablebooleanAdds status + published_at. Draft/publish workflow.
paranoidbooleanAdds deleted_at. Delete tombstones instead of removing.
i18nbooleanAdds locale + locale_group_id. One row per language.
groupsobjectCollapsible sections of the entry form. No storage.
pathstringsingle() only. Must start with /.

publishable

Adds status (draft | published, default draft) and nullable published_at. Without it every row is simply live.

It also changes what required means:

Typerequired: true meansEnforced by
publishable: trueRequired to publish; the column stays nullable, drafts save incompleteThe publish gate
not publishableNOT NULL columnValidation on every write, and Postgres

See publishing.

paranoid

Adds nullable deleted_at. Delete stamps it, restore clears it, /permanent removes the row. See publishing and deletion.

i18n

Adds locale (text, not null) and locale_group_id (uuid, defaults to a new uuid), plus a unique index on (locale_group_id, locale). Which locales exist is the localization plugin’s job — see localization.

groups

Named sections of the entry form, drawn in declaration order. Added in 0.7.0.

groups: {
    seo: { label: 'SEO', description: 'Search and social previews.' },
    advanced: { label: 'Advanced', collapsed: true }
},
fields: {
    title: field.text({ required: true }),                       // above every section
    metaTitle: field.text({ admin: { group: 'seo' } }),
    metaDescription: field.text({ admin: { group: 'seo' } }),
    metadata: field.json({ admin: { group: 'advanced' } })
}
Group keyRequiredEffect
labelyesSection heading.
descriptionnoOne line under the heading.
collapsednoStart folded. The editor remembers each reader’s own choice after that.
  • A field joins a section with admin: { group: '<key>' }.
  • Fields with no group render above every section.
  • From three sections on (fields above them, if any, count as one: Basics) the editor maps them in On this page beside the form, or a Sections menu on a narrow screen.
  • Presentation only: no column, no API change beyond groups in the schema read.

Each of these fails at define time (server boot):

Group "product.Pricing!" must be an identifier (letters, digits, underscores; starting with a letter).
Group "product.pricing" needs a label.
Field "product.price" names group "pricng", which "product" does not declare.
Group "product.advanced" has no fields — set admin.group on at least one field, or remove it.

Field width and rows (admin.width, admin.row) are on fields.

Naming rules

  • Type name: snake_case, ^[a-z][a-z0-9_]*$. It is the table name, the API path segment and the permission subject — don’t rename it casually.
  • Field names: camelCase by convention, snake_cased into columns (readingMinutes → reading_minutes).
  • GraphQL names derive from the type name: site_settings → SiteSettings, siteSettings; category → categories. Two types that collide fail at boot.

Reserved column names

Nine names belong to the platform, on every type, whether or not the flag that uses them is set:

id, workspace_id, status, created_at, updated_at, published_at, deleted_at, locale, locale_group_id

A field that maps onto one fails at define time:

Field "status" on "product" maps to column "status", which collides with an envelope column.

So do two fields that snake_case to the same column. A single relation occupies <field>_id, so a relation author and a text field authorId collide too.

What gets generated

content_<name>, with four envelope columns before your fields:

ColumnTypeNotes
iduuidPrimary key, random default.
workspace_iduuidOwning workspace. Not a foreign key.
created_attimestamptzNot null, defaults to now.
updated_attimestamptzNot null, refreshed on every save.

Plus status/published_at, deleted_at, locale/locale_group_id when the flags are set. A many-relation adds a join table content_<type>_<field>.

workspace_id has no FK because workspaces lives in another plugin’s schema. Isolation is enforced in application code on every read and write.

Registering a type

One file per type, at a fixed path, exporting a type with the file’s name:

  • apps/server/src/content/collections/<name>.ts — a collection()
  • apps/server/src/content/pages/<name>.ts — a single()

Then regenerate the content manifest, src/content/index.ts:

npx orthacms content sync      # npm run content:sync

The manifest does two jobs: it lists the types ContentPlugin receives, and it re-exports every table so drizzle-kit can diff them into a migration. It is generated — do not edit it. For two types it reads:

// @orthacms-generated — the content manifest. Do not edit by hand.
// …

import {
    contentEntryRevisions,
    joinTableOf,
    type AnyContentType
} from '@orthacms/content-server/define';
import { article } from './collections/article';
import { author } from './collections/author';

/** Every content type registered with ContentPlugin. */
export const contentTypes: readonly AnyContentType[] = [article, author];

/* Main tables — one `content_<name>` per type. */
export const articleTable = article.table;
export const authorTable = author.table;

/* Join tables — one per many-relation. `joinTableOf` throws if one is gone. */
export const articleTagsJoinTable = joinTableOf(article, 'tags');

/* The revision store — one fixed table, owned by content-server. */
export const contentEntryRevisionsTable = contentEntryRevisions;

plugins.ts registers the list once and never changes when you add a type. In a generated app:

// apps/server/src/plugins.ts
import { join } from 'node:path';
import { ContentPlugin } from '@orthacms/content-server';
import { contentTypes } from './content';

const content = ContentPlugin({
    types: contentTypes,
    migrations: {
        // the app root is three levels up from both src/ and dist/server/src/
        dir: () => join(__dirname, '../../../apps/server/migrations'),
        table: '__drizzle_migrations_content'
    }
});

Import from /define in collection files

Collection files and content/index.ts import from @orthacms/content-server/define, not the package root. drizzle-kit bundles the schema file with plain esbuild, which rejects the NestJS decorators the main entry pulls in.

  • Without the migrations descriptor the types are served but their tables are never created.
  • A table not re-exported from index.ts is invisible to drizzle-kit: the type fails at runtime against a missing table. content sync writes every export, which is why the manifest is generated.
  • A file that breaks the convention — the wrong export name, a single under collections/ — stops content sync with the reason.
  • A new app ships the empty manifest, drizzle.config.ts and a baseline migration, so the first type edits no plumbing.

Changing a type

A model change is a code change plus a migration, committed together:

npx orthacms generate --name=add_product_sku
npx orthacms migrate

See migrations. In development the schema builder does both for a type it owns, and refuses renames, retypes and flag changes, which need a data migration.

No runtime schema editing

Nothing changes the schema of a running server. The schema builder edits the source and generates a migration in development only; in production its page is read-only. Every model change is a deployment.