Twelve field builders are available on every content type. None of them is a plugin you install.
import { field } from '@apograph/content-server/define';
fields: {
title: field.text({ required: true, maxLength: 200 }),
body: field.richtext(),
price: field.money({ min: 0 }),
featured: field.boolean()
}The twelve types
| Builder | Stores | Postgres column |
|---|---|---|
field.text | A string | text |
field.richtext | A structured document | jsonb |
field.number | Integer or floating point | integer when integer: true, else double precision |
field.money | An amount in minor units | integer |
field.boolean | True or false | boolean |
field.date | A calendar date, no time | date |
field.datetime | An instant | timestamptz |
field.select | One value from a list | text |
field.multiselect | Several values from a list | jsonb |
field.json | An arbitrary document | jsonb |
field.relation | A link to another type | <field>_id uuid FK, or a join table |
field.media | One or more library assets | uuid, or jsonb when multiple |
Options every field takes
| Option | Type | What it does |
|---|---|---|
required | boolean | Rejects empty values. On a publishable type this is checked at publish time and the column stays nullable; otherwise the column is NOT NULL. |
localized | boolean | This value differs per locale. Only valid on an i18n type — rejected at define time otherwise. |
lang | string | A BCP-47 tag naming the language this field is written in, when it differs from the entry’s. |
admin | object | Presentation hints forwarded to the admin form. |
An “empty” value means the same thing everywhere in the system: null,
undefined, a whitespace-only string, or an empty array.
admin
The admin bag is passed through to the form untouched, so a project can put
its own hints in it. The stock admin reads these:
| Key | Effect |
|---|---|
label | The field’s label. Falls back to the field name. |
description | Help text under the input. |
placeholder | Input placeholder. |
widget | Render hint — textarea, slug, color. |
hidden | Keeps the field out of the default form. It is still served by the API. |
excerpt: field.text({
maxLength: 320,
admin: { widget: 'textarea', description: 'Card and meta summary.' }
})lang
lang is for a field whose whole value is in another language — an
originalTitle, a motto, a term left untranslated. It becomes a lang
attribute when the value is rendered, which is what lets a screen reader switch
pronunciation (WCAG 3.1.2).
originalTitle: field.text({ lang: 'ja' })The tag is checked for well-formedness when the type is defined, so en_US (a
POSIX locale) or english fails at boot rather than being silently ignored by
assistive technology at render time.
Inside a rich-text body, an individual passage carries its own language marker, so this is not the mechanism for a quoted sentence — see rich text.
localized
On an i18n content type, a field is shared across the translation group
unless it is marked localized: true. Shared means one value for the record:
editing it in English writes it to every language’s row.
title: field.text({ required: true, localized: true }), // per language
authorId: field.text() // one for the recordText
field.text({ minLength: 3, maxLength: 200, pattern: '^[a-z0-9-]+$' })| Option | Effect |
|---|---|
minLength | Minimum length. |
maxLength | Maximum length. |
pattern | An ECMAScript regex source the value must match. |
Lengths are counted in user-perceived characters, not UTF-16 code units. An
emoji is one character, and so is a family emoji made of several joined code
points — which is what a maxLength: 200 promise means to whoever is typing.
Number and money
readingMinutes: field.number({ integer: true, min: 0 }),
rating: field.number({ min: 0, max: 5 }),
price: field.money({ min: 0 })number takes min, max and integer. Setting integer: true changes the
column from double precision to integer — so adding it to an existing field
is a type change, not just a validation change.
money stores integer minor units: cents, pence, sen. It is an integer
column, so the arithmetic is exact and there is no float drift. min and max
are also in minor units, so min: 0 and a price of 1999 means £19.99.
Money carries no currency
field.money stores an amount, not a currency. If your content is priced in
more than one currency, the currency is a separate field — a select of the
codes you support, most likely.
Boolean
featured: field.boolean({ admin: { label: 'Feature on home page' } })A required boolean gets DEFAULT false, so an omitted value stores a
concrete false rather than failing a not-null check. An optional boolean is
genuinely three-valued: true, false, or never set.
Date and datetime
editorialDate: field.date(), // a calendar date, no time, no zone
embargoUntil: field.datetime() // an instant, stored with its time zoneUse date when the value is a day as humans mean it — a publication date on a
masthead, a birthday — and datetime when it is a moment in time.
Select and multiselect
layout: field.select({ options: ['standard', 'wide', 'full_bleed'] }),
audiences: field.multiselect({ options: ['general', 'developers', 'designers'] })select stores one of the listed strings in a text column. multiselect
stores an array of them in jsonb.
The options live in the type declaration, which means adding an option is a deployment. It also means the set is guaranteed and can be relied on by a front end — there is no way for a stray value to appear.
JSON
metadata: field.json({ admin: { description: 'Freeform structured metadata.' } })An arbitrary jsonb document, validated only as being valid JSON. It is the
escape hatch for a shape that is genuinely yours, and it is worth being
suspicious of: a json field is not filterable in any structured way, is not
diffable field-by-field in version history, and gives the editor no form beyond
a text area.
Rich text
body: field.richtext({ maxLength: 20000 })A structured document rather than an HTML string. minLength and maxLength
count the body’s text, not its markup, so bolding a word does not cost the
author <strong></strong> out of their budget. It also takes
structure: 'off' to opt out of the structural rules. This one has enough to
it that it gets its own page.
Rich text is not filterable or sortable
A structured document has no meaningful equals or starts with, so richtext
fields are excluded from the filter grammar and the sort whitelist. Free-text
?search= does still reach them — the column is cast to text for the match,
which searches the serialized tree.
Media
coverImage: field.media({ accept: { kinds: ['image'] } }),
gallery: field.media({ multiple: true, accept: { mimeTypes: ['image/*'] } })Attaches Media Library assets. Covered on media fields.
Relations
author: field.relation({ to: () => author, required: true }),
tags: field.relation({ to: () => tag, many: true })The target is a thunk so that two collection files can import each other. Relations have four cardinalities and their own storage rules — see relations.
Where validation actually happens
The rules above are serialized to the admin so the same constraints render in the form, but the admin’s copy is a courtesy. The server’s validation service is the authority, and it re-checks everything on every write. A client that skips the form does not skip the rules.
There is one asymmetry worth knowing about: a key that is not a declared field
is dropped silently rather than rejected. That is deliberate in one
direction — a stored revision snapshot that outlived a removed field is still
restorable — and a rough edge in the other, because a misspelt field name in a
hand-written request writes nothing and says nothing. The public API’s
?fields= parameter and the copilot’s write tools do reject unknown names,
because there the caller is naming something they expect back.