Skip to content

Fields ​

What you'll learn

Every field type, its options, what it looks like in the admin, and how it is stored.

Before this page: Configuration.

Every field has a name and a type. Common options:

Option
labelString or { en, th }. Defaults to the humanized name.
requiredMust have a value (skipped while saving a draft).
defaultValueUsed when creating a document without this field.
uniqueNo two documents may share the value (top-level fields).
indexCreate a database index.
validate(value, { data, operation }) => true | 'error message', may be async.
access{ read, update } field-level access.
hiddenStored, but never returned by the API nor accepted as input.
localizedOne value per locale; see Localization.
position'sidebar': shown in the edit page's side panel (for top-level fields such as a category, tags or a date).
admin{ component, after }: admin components instead of the input, and below the field.

Types ​

TypeValueOptions
textstringminLength, maxLength
textareastringminLength, maxLength
emailstring, stored lowercase
numbernumbermin, max
booleanboolean
dateISO 8601 string (accepts Date)
selectone of the options, or an array with hasManyoptions, hasMany
slugURL-safe string, unique in the collectionfrom
jsonany JSON value
richTextTiptap JSON documentsee Rich text
uploadid of a media documentsee Uploads
relationshipid(s) of documents in another collectionto, hasMany
arraylist of rows, each with an id and sub-fieldsfields, minRows, maxRows
groupnested objectfields
blockslist of rows of different kinds, each with id and blockTypeblocks, minRows, maxRows

select ​

ts
{ name: 'kind', type: 'select', options: ['news', { label: { en: 'Blog', th: 'บล็อก' }, value: 'blog' }] }
{ name: 'tags', type: 'select', options: ['vue', 'react'], hasMany: true }

slug ​

ts
{ name: 'slug', type: 'slug', from: 'title' }

Filled from title when empty. Letters of any script are kept (สวัสดี ชาวโลก → สวัสดี-ชาวโลก); duplicates get -2, -3. Changing the title later does not change the slug.

uniqueWithin: 'parent' makes slugs unique only among documents with the same value of another field, e.g. pages under the same parent (as the nested pages plugin does).

relationship ​

ts
{ name: 'author', type: 'relationship', to: 'users' }
{ name: 'related', type: 'relationship', to: 'posts', hasMany: true }

Ids are checked to exist when saving. Reads populate related documents to depth levels (default 1, max 3); at depth 0 you get ids. Deleted or unreadable documents become null, or are dropped from hasMany lists.

filterOptions limits which documents a relationship may point to. It runs on the server with the document's id (undefined while creating) and the user, and returns a where on the target collection, or true for any. The admin's picker offers only those, and saving checks them:

ts
{
  name: 'manager',
  type: 'relationship',
  to: 'users',
  filterOptions: ({ id }) => ({ role: { equals: 'admin' } }),
}

array and group ​

ts
{
  name: 'links',
  type: 'array',
  maxRows: 5,
  fields: [
    { name: 'label', type: 'text', required: true },
    { name: 'url', type: 'text' },
  ],
}
{ name: 'seo', type: 'group', fields: [{ name: 'title', type: 'text' }] }

Updating an array replaces all its rows; keep a row's id to keep its identity.

blocks ​

Rows of different kinds, for pages editors lay out themselves:

ts
{
  name: 'layout',
  type: 'blocks',
  blocks: [
    {
      slug: 'hero',
      labels: { singular: 'Hero' },
      fields: [
        { name: 'heading', type: 'text', required: true },
        { name: 'image', type: 'upload' },
      ],
    },
    { slug: 'text', fields: [{ name: 'body', type: 'richText' }] },
  ],
}

Each row is { id, blockType, ...fields }; blockType picks the block. Rows are validated, relationships and uploads inside them populated, and the generated types are a union of the block kinds.

Blocks are stored as JSON. where can look inside them: a field name means that field in whichever block has it, and a document matches when some block does.

ts
await cms.find('pages', { where: { 'layout.blockType': { equals: 'hero' } } })
await cms.find('pages', { where: { 'layout.heading': { like: 'sale' } } })
await cms.find('pages', { where: { 'layout.heading.en': { equals: 'Hello' } } }) // a locale
await cms.find('pages', { where: { layout: { exists: false } } }) // no blocks at all

Lists inside blocks work the same way: some row must match.

ts
await cms.find('pages', { where: { 'layout.meta.tone': { equals: 'warm' } } }) // a group
await cms.find('pages', { where: { 'layout.items.title': { equals: 'One' } } }) // an array
await cms.find('pages', { where: { 'layout.tags': { in: ['sale'] } } }) // hasMany values
await cms.find('pages', { where: { 'layout.content.blockType': { equals: 'quote' } } }) // blocks

sort uses the first value found, going through the blocks (and the rows of lists in them) in order: sort: '-layout.columns', sort: 'layout.items.title'. These queries read the JSON of each document, so they suit filters more than large, hot lists.

How fields are stored ​

Each collection is a table; fields are columns (group fields are flattened: seo.title → seo_title). Arrays and hasMany values live in child tables; blocks are a JSON column. Two fields that would map to the same column are reported as a config error.

Next steps ​

Released under the MIT License.