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:
| Surface | Result |
|---|---|
| Table | content_product — envelope columns plus one column per field |
| Admin | A “Products” list and an entry form with two sections, Pricing and Advanced |
| REST | GET /api/v1/content/product, GET /api/v1/content/product/:id, … |
| GraphQL | product(id:), products(...), createProduct, publishProduct, … |
| Schema | GET /api/v1/content-types/product |
Read the schema back over the public API:
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
| Option | Type | What it does |
|---|---|---|
fields | object | The field map. Keys become column names, snake_cased. Required, non-empty. |
label | string | Name in the admin. Defaults to the type name. |
description | string | Shown in the workspace wizard and type pickers. |
publishable | boolean | Adds status + published_at. Draft/publish workflow. |
paranoid | boolean | Adds deleted_at. Delete tombstones instead of removing. |
i18n | boolean | Adds locale + locale_group_id. One row per language. |
groups | object | Collapsible sections of the entry form. No storage. |
path | string | single() 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:
| Type | required: true means | Enforced by |
|---|---|---|
publishable: true | Required to publish; the column stays nullable, drafts save incomplete | The publish gate |
| not publishable | NOT NULL column | Validation 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 key | Required | Effect |
|---|---|---|
label | yes | Section heading. |
description | no | One line under the heading. |
collapsed | no | Start folded. The editor remembers each reader’s own choice after that. |
- A field joins a section with
admin: { group: '<key>' }. - Fields with no
grouprender 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
groupsin 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:
| Column | Type | Notes |
|---|---|---|
id | uuid | Primary key, random default. |
workspace_id | uuid | Owning workspace. Not a foreign key. |
created_at | timestamptz | Not null, defaults to now. |
updated_at | timestamptz | Not 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— acollection()apps/server/src/content/pages/<name>.ts— asingle()
Then regenerate the content manifest, src/content/index.ts:
npx orthacms content sync # npm run content:syncThe 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
migrationsdescriptor the types are served but their tables are never created. - A table not re-exported from
index.tsis invisible to drizzle-kit: the type fails at runtime against a missing table.content syncwrites every export, which is why the manifest is generated. - A file that breaks the convention — the wrong export name, a single under
collections/— stopscontent syncwith the reason. - A new app ships the empty manifest,
drizzle.config.tsand 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 migrateSee 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.