Twelve field types, built by thirteen builders (field.relationInverse is a
second way to declare a relation). All ship with content-server; none is a
plugin.
import { collection, field } from '@orthacms/content-server/define';
export const event = collection('event', {
publishable: true,
fields: {
title: field.text({ required: true, maxLength: 200 }),
body: field.richtext(),
capacity: field.number({ integer: true, min: 0 }),
price: field.money({ min: 0 }),
featured: field.boolean(),
day: field.date(),
startsAt: field.datetime(),
format: field.select({ options: ['talk', 'workshop', 'panel'] }),
topics: field.multiselect({ options: ['design', 'code', 'ops'] }),
extra: field.json(),
venue: field.relation({ to: () => venue }),
poster: field.media({ accept: { kinds: ['image'] } })
}
});The twelve types
| Builder | Stores | Postgres column | JSON on the API | GraphQL |
|---|---|---|---|---|
field.text | A string | text | "Hello" | String |
field.richtext | A document tree | jsonb | { "type": "doc", … } | JSON |
field.number | Integer or float | integer if integer: true, else double precision | 42, 4.5 | Int / Float |
field.money | Minor units | integer | 1999 | Float |
field.boolean | True/false | boolean | true | Boolean |
field.date | A calendar day | date | "2026-10-01" | Date |
field.datetime | An instant | timestamptz | "2026-10-01T09:00:00.000Z" | DateTime |
field.select | One option | text | "talk" | enum EventFormat |
field.multiselect | Several options | jsonb | ["code", "ops"] | [EventTopics!] |
field.json | Any JSON | jsonb | any | JSON |
field.relation | A link | <field>_id uuid FK, or a join table | not in values — expand it | target object, or …Links page |
field.media | Library asset(s) | uuid, or jsonb when multiple | not in values — expand with ?media=preview | [MediaAsset!]! |
A select whose options are not valid GraphQL names (full-bleed, 2x) falls
back to String.
A public read of the entry above:
{
"id": "0b9e…",
"status": "published",
"createdAt": "2026-09-20T10:02:11.000Z",
"updatedAt": "2026-09-28T08:40:00.000Z",
"publishedAt": "2026-09-28T08:40:00.000Z",
"values": {
"title": "Structured content in practice",
"body": { "type": "doc", "content": [] },
"capacity": 120,
"price": 1999,
"featured": true,
"day": "2026-10-01",
"startsAt": "2026-10-01T09:00:00.000Z",
"format": "talk",
"topics": ["code", "ops"],
"extra": { "room": "B" }
}
}Options every field takes
| Option | Type | What it does |
|---|---|---|
required | boolean | Rejects empty values. Publish-time rule on a publishable type; NOT NULL otherwise. |
localized | boolean | Value differs per locale. Only on an i18n type — rejected at define time otherwise. |
lang | string | BCP-47 tag for a field written in another language than the entry. |
admin | object | Presentation hints for the admin form. |
“Empty” means: null, undefined, a whitespace-only string, or [].
admin
Passed to the admin untouched (and served in the schema), so you can add your own keys. The stock admin reads:
| Key | Effect |
|---|---|
label | Field label. Defaults to the field name. |
description | Help text under the input. Also the GraphQL field description. |
placeholder | Input placeholder. |
widget | slug marks the type’s slug field. textarea on a richtext field swaps the editor for a plain HTML box. Other values (email, color) pass through for your own admin plugins. |
hidden | Hidden from the default form. Still served by the API. |
group | The form section — a key of the type’s groups. |
row | Shares a line with the other field carrying the same key. |
width | 'half' or 'full' (default). |
Form layout
Three admin keys shape the entry form. All added in 0.7.0; none affects
storage or the API.
The form is a two-column grid. Every field is full width unless it asks otherwise — the admin never guesses a width from the field type.
Since 0.12.0 it is one form: relation and media fields sit in it beside the
others and join a section with admin.group like any other field, where they
used to have tabs of their own. Inside a section the declaration order holds.
The fields above every section are ordered by kind — simple inputs first, then
choices, then rich text and JSON, then relations and media — keeping
declaration order within each kind.
export const event = collection('event', {
groups: {
schedule: { label: 'Schedule' },
tickets: { label: 'Tickets', description: 'Pricing and capacity.', collapsed: true }
},
fields: {
title: field.text({ required: true }), // full width, above sections
startsAt: field.datetime({ admin: { group: 'schedule', row: 'when' } }),
endsAt: field.datetime({ admin: { group: 'schedule', row: 'when' } }),
price: field.money({ min: 0, admin: { group: 'tickets', row: 'price' } }),
currency: field.select({
options: ['EUR', 'USD'],
admin: { group: 'tickets', row: 'price' }
}),
sku: field.text({ admin: { group: 'tickets', width: 'half' } }),
capacity: field.number({ integer: true, admin: { group: 'tickets' } })
}
});Renders as:
Title ..................................... (full)
▾ Schedule
Starts at ............ | Ends at ........... (row "when")
▸ Tickets — Pricing and capacity. (collapsed)
Price ................ | Currency .......... (row "price")
SKU .................. | (width: half)
Capacity .................................. (full)admin.group
Puts the field in a collapsible section declared in the type’s groups. See
content types. An unknown key fails at
boot:
Field "event.price" names group "ticket", which "event" does not declare.admin.row
Fields with the same row string share one line.
- At most two per row. A third field with that key starts a new line.
- The row sits where its first member is, even if other fields come between the members in declaration order.
- A row with one member renders as a normal field, so a typo cannot leave a half-empty line.
- Rows are declared, never inferred.
Use it for fields read together: a start and an end, an amount and its unit.
admin.width
'half' gives a lone field one column. 'full' is the default. Anything else
fails at boot — a typo would otherwise silently render full:
Field "event.sku" has admin.width "halff" — use 'half' or 'full'.Booleans are the one exception to the default: without a width they size to
their content.
lang
For a field whose whole value is in another language — an originalTitle,
a motto. It becomes a lang attribute when rendered, so screen readers switch
pronunciation (WCAG 3.1.2).
originalTitle: field.text({ lang: 'ja' })Checked at define time: en_US or english fails at boot.
Invalid BCP-47 language tag "en_US" on a text field. Expected something like "en", "en-GB" or "zh-Hans".For a quoted passage inside a body, use a language mark — see rich text.
localized
On an i18n type a field is shared across the translation group unless it
says localized: true. Editing a shared field in English writes it to every
language’s row.
title: field.text({ required: true, localized: true }), // per language
sku: field.text() // one value for the recordText
field.text({ minLength: 3, maxLength: 200, pattern: '^[a-z0-9-]+$' })minLength/maxLengthcount user-perceived characters: an emoji — even a joined family emoji — is one.patternis an ECMAScript regex source. An invalid one fails at boot.
Number and money
readingMinutes: field.number({ integer: true, min: 0 }),
rating: field.number({ min: 0, max: 5 }),
price: field.money({ min: 0, max: 1_000_000 })integer: truechanges the column tointeger— adding it later is a type change, not just a rule.moneyis an integer in minor units:1999is 19.99.19.99is rejected.min/maxare minor units too.
Money carries no currency
Store the currency in its own field — a select of the codes you support.
Boolean
featured: field.boolean({ admin: { label: 'Feature on home page' } })A required boolean gets DEFAULT false. An optional one is three-valued:
true, false, never set.
Date and datetime
editorialDate: field.date(), // "2026-10-01" — a day, no time, no zone
embargoUntil: field.datetime() // "2026-10-01T09:00:00Z" — an instantA datetime must carry a time; "2026-10-01" is rejected, not coerced to midnight.
Select and multiselect
layout: field.select({ options: ['standard', 'wide', 'full_bleed'] }),
audiences: field.multiselect({ options: ['general', 'developers', 'designers'] })Options live in code, so a new option is a deployment — and a front end can rely on the set.
JSON
metadata: field.json({ admin: { description: 'Freeform structured metadata.' } })Any valid JSON. Not filterable, not diffed per key in history, and a plain text area in the form. Use it sparingly.
Rich text
body: field.richtext({ maxLength: 20000 }) // counts text, not markup
emailTemplate: field.richtext({ structure: 'off' }) // skip structural rulesA structured document, not HTML. See rich text.
Rich text is not filterable or sortable
richtext is excluded from ?filter= and ?sort=. ?search= still reaches
it (the column is cast to text).
Media
coverImage: field.media({ accept: { kinds: ['image'] } }),
gallery: field.media({ multiple: true, accept: { mimeTypes: ['image/*'] } })A value is an asset id, or { id, alt, decorative }. See
media fields.
Relations
author: field.relation({ to: () => author, required: true, onDelete: 'restrict' }),
tags: field.relation({ to: () => tag, many: true }),
comments: field.relationInverse({ of: () => comment, field: 'article' })The target is a thunk so two files can import each other. See relations.
Where validation happens
The rules are sent to the admin so the form can show them, but the server’s validation is the authority: every write to a non-publishable type, and the publish of a publishable one. See validation.
Unknown field names are dropped silently on writes
{"values": {"titel": "…"}} stores nothing and returns success. The public
API’s ?fields= and the copilot’s write tools do reject unknown names.