Features Apograph CMS on GitHub

Fields

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

Twelve builders, what each one stores, and the options every one of them shares.

Documents 0.5.2 Updated Edit this page Report a problem

On this page

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

BuilderStoresPostgres column
field.textA stringtext
field.richtextA structured documentjsonb
field.numberInteger or floating pointinteger when integer: true, else double precision
field.moneyAn amount in minor unitsinteger
field.booleanTrue or falseboolean
field.dateA calendar date, no timedate
field.datetimeAn instanttimestamptz
field.selectOne value from a listtext
field.multiselectSeveral values from a listjsonb
field.jsonAn arbitrary documentjsonb
field.relationA link to another type<field>_id uuid FK, or a join table
field.mediaOne or more library assetsuuid, or jsonb when multiple

Options every field takes

OptionTypeWhat it does
requiredbooleanRejects empty values. On a publishable type this is checked at publish time and the column stays nullable; otherwise the column is NOT NULL.
localizedbooleanThis value differs per locale. Only valid on an i18n type — rejected at define time otherwise.
langstringA BCP-47 tag naming the language this field is written in, when it differs from the entry’s.
adminobjectPresentation 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:

KeyEffect
labelThe field’s label. Falls back to the field name.
descriptionHelp text under the input.
placeholderInput placeholder.
widgetRender hint — textarea, slug, color.
hiddenKeeps 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 record

Text

field.text({ minLength: 3, maxLength: 200, pattern: '^[a-z0-9-]+$' })
OptionEffect
minLengthMinimum length.
maxLengthMaximum length.
patternAn 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 zone

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