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

Fields

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

Twelve field types, what each one stores, the options every one of them shares, and form layout.

Documents 0.13.1 Updated Edit this page Report a problem

On this page

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

BuilderStoresPostgres columnJSON on the APIGraphQL
field.textA stringtext"Hello"String
field.richtextA document treejsonb{ "type": "doc", … }JSON
field.numberInteger or floatinteger if integer: true, else double precision42, 4.5Int / Float
field.moneyMinor unitsinteger1999Float
field.booleanTrue/falsebooleantrueBoolean
field.dateA calendar daydate"2026-10-01"Date
field.datetimeAn instanttimestamptz"2026-10-01T09:00:00.000Z"DateTime
field.selectOne optiontext"talk"enum EventFormat
field.multiselectSeveral optionsjsonb["code", "ops"][EventTopics!]
field.jsonAny JSONjsonbanyJSON
field.relationA link<field>_id uuid FK, or a join tablenot in values — expand ittarget object, or …Links page
field.mediaLibrary asset(s)uuid, or jsonb when multiplenot 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

OptionTypeWhat it does
requiredbooleanRejects empty values. Publish-time rule on a publishable type; NOT NULL otherwise.
localizedbooleanValue differs per locale. Only on an i18n type — rejected at define time otherwise.
langstringBCP-47 tag for a field written in another language than the entry.
adminobjectPresentation 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:

KeyEffect
labelField label. Defaults to the field name.
descriptionHelp text under the input. Also the GraphQL field description.
placeholderInput placeholder.
widgetslug 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.
hiddenHidden from the default form. Still served by the API.
groupThe form section — a key of the type’s groups.
rowShares 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 record

Text

field.text({ minLength: 3, maxLength: 200, pattern: '^[a-z0-9-]+$' })
  • minLength / maxLength count user-perceived characters: an emoji — even a joined family emoji — is one.
  • pattern is 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: true changes the column to integer — adding it later is a type change, not just a rule.
  • money is an integer in minor units: 1999 is 19.99. 19.99 is rejected. min/max are 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 instant

A 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 rules

A 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.