Logo VitNode

Fields

Reference for every Content Engine field helper, such as field.text, field.relation and field.blocks, with its options, defaults, Postgres column and AdminCP form control.

A field is one property of a content type, such as a title or a view count. You build each one with a helper from field, exported by @vitnode/core/content. The helper decides three things at once: the Postgres column, the validation of every write and the control in the AdminCP form.

plugins/example/src/content/article.ts
import { defineContentType, field } from "@vitnode/core/content";

export const articleContentType = defineContentType({
  id: "example.article",
  tableName: "example_articles",
  fields: {
    title: field.text({ required: true, minLength: 3, maxLength: 200 }),
    slug: field.slug({ source: "title" }),
    views: field.number({ integer: true, min: 0, defaultValue: 0 }),
    featured: field.boolean({ defaultValue: false }),
    author: field.user(),
  },
});

The example plugin's article form shows most controls. The first half holds the plain values:

Create Article dialog with inputs for Title, Slug and Reference code, an Excerpt textarea with an AI button, a Views number input with minus and plus buttons, and switches for Featured and No index

The second half holds the references: a user picker, two file dropzones and a relation picker.

Lower half of the Create Article dialog: an Author select, an Animation dropzone that accepts one GIF up to 10 MB, a Gallery dropzone for up to 8 images and a Category search picker

Field helpers

HelperPostgres columnAdminCP controlCan be localized
field.text()varchar(maxLength), 255 by defaultText inputYes
field.textarea()textTextareaYes
field.richText()jsonbRich text editorYes
field.number()integer or double precisionNumber input with steppersNo
field.boolean()booleanSwitchNo
field.enum()varchar(length), 64 by defaultSelect or radio buttonsNo
field.dateTime()timestampDate and time pickerNo
field.slug()varchar(maxLength), 160 by default, uniqueText inputYes
field.user()integer referencing core_users.idUser pickerNo
field.file()integer referencing core_files.idFile dropzoneNo
field.relation()integer referencing the target tableSearch pickerNo
field.group()One column per leafA fieldset with the leaf controlsYes, as a whole
field.repeatable()A child tableA list of rows you can add, move, removeNo
field.blocks()jsonb, NOT NULL DEFAULT '[]'Not in the form; edited as widgetsYes

user, file and relation with multiple: true store their values in a junction table instead of a column. Localized fields live in the translation table. See Translations.

Shared options

Every helper except slug, repeatable and blocks accepts these:

NameTypeRequiredDefaultDescription
requiredbooleanNofalseA create request must include the field
nullablebooleanNofalseThe column accepts NULL, and so does validation
descriptionstringNoText or an i18n key shown under the control in the AdminCP

A field that is neither required nor nullable needs a fallback, or defineContentType throws Field "x" is neither required nor nullable, so it needs a default value. A defaultValue, defaultNow: true on a date and source on a slug all count as a fallback. On update every field is optional.

required: true on a text field still accepts an empty string. Add minLength: 1 when you need actual text.

Text and textarea

code: field.text({ required: true, maxLength: 100, unique: true }),
excerpt: field.textarea({ maxLength: 500, nullable: true }),
NameTypeRequiredDefaultDescription
minLengthnumberNoShortest accepted value, in characters
maxLengthnumberNo255 column on textLongest accepted value. On text it is also the varchar length
defaultValuestringNoWritten when a create leaves the field out. Becomes the column DEFAULT
uniquebooleanNofalsetext only. Adds a unique index. Has no effect on a localized field
localizedbooleanNofalseOne value per language
aiobjectNoAdds an AI suggestion button, see AI suggestions

Set maxLength on every text field. Without it, validation accepts any length, and Postgres rejects anything over 255 characters with a database error instead of a validation message. A defaultValue must satisfy the field's own minLength and maxLength.

Rich text

content: field.richText({ localized: true, required: true }),
NameTypeRequiredDefaultDescription
maxBytesnumberNo1 MBLargest document, measured as UTF-8 JSON. At most 16 MB
localizedbooleanNofalseOne document per language

The value is a ProseMirror JSON document. A rich text field has no defaultValue, so set required or nullable. With required: true a document without any text or media is refused. Documents nested deeper than 64 levels are refused too. Rich text covers rendering and the plain text and HTML helpers.

Number

views: field.number({ integer: true, min: 0, defaultValue: 0 }),
NameTypeRequiredDefaultDescription
integerbooleanYestrue makes an integer column, false a double precision one
min, maxnumberNoAccepted range. With integer: true a whole number must fit between them
defaultValuenumberNoMust sit inside the range, and be whole when integer: true

Boolean

featured: field.boolean({ defaultValue: false }),

The only option besides the shared ones is defaultValue. A nullable boolean, such as noIndex: field.boolean({ nullable: true }), can hold true, false or null.

Enum

difficulty: field.enum({ values: ["easy", "medium", "hard"], defaultValue: "easy", display: "radio" }),
NameTypeRequiredDefaultDescription
valuesstring[]YesNon-empty, without duplicates
defaultValueone of valuesNo
display"select" | "radio"No"select"AdminCP control
lengthnumberNo64varchar length. Every value must fit

The column is a plain varchar, not a Postgres enum, so adding a value later needs no type migration. The AdminCP translates each value from {pluginId}.content.{entity}.enums.{field}.{value} when that key exists.

Date and time

eventDate: field.dateTime({ defaultNow: true }),
NameTypeRequiredDefaultDescription
defaultNowbooleanNofalseFills the column with the current time on insert

There is no defaultValue. Send dates as ISO 8601 strings.

Slug

slug: field.slug({ source: "title" }),
NameTypeRequiredDefaultDescription
sourcename of a text fieldNoFills the slug from that field when a create sends no slug
maxLengthnumberNo160varchar length and the point where long slugs are cut
localizedbooleanNofalseOne slug per language
descriptionstringNoShown under the input

A slug has no required or nullable option. It is required when there is no source, and it is never empty. Every value is normalized, so "Weekend Pancakes!" becomes weekend-pancakes. Slugs are unique across the table, or within each language when localized. The slug is derived only on create, so renaming the title later keeps the old URL. source must name a field.text(), and a localized slug needs a localized source.

User

author: field.user(),
authorId: field.user({ multiple: true, ordered: true, min: 1 }),
NameTypeRequiredDefaultDescription
multiplebooleanNofalseSeveral people, stored in a junction table
orderedbooleanNofalseKeeps the order editors arrange
minnumberNoFewest people. Needs multiple
onDelete"cascade" | "restrict" | "set null"Nosee belowWhat deleting the user does

A single user field is nullable unless you set required: true. onDelete defaults to "set null" for a nullable single user, "restrict" for a required one and "cascade" for multiple. User fields cannot be listed in publicApi.fields.

File

animation: field.file({
  maxBytes: 10 * 1024 * 1024,
  allowedExtensions: [".gif"],
  allowedMimeTypes: ["image/gif"],
}),
gallery: field.file({ multiple: true, min: 1, max: 8, maxBytes: 5 * 1024 * 1024 }),
NameTypeRequiredDefaultDescription
maxBytesnumberYesLargest accepted file, in bytes
allowedExtensionsstring[]NoanyLowercased, with a leading dot added
allowedMimeTypesstring[]NoanyLowercased
multiplebooleanNofalseSeveral files in a junction table
minnumberNoFewest files, at least 1. Needs multiple
maxnumberNo20Most files, up to 200. Needs multiple
orderedbooleanNosame as multipleKeeps the order editors arrange

A single file field is nullable unless you set required: true, and it cannot have a default or be localized. Translate a caption or alt text in a separate localized text field instead. Uploads go through the storage adapter and land in core_files. A file in use cannot be deleted, because the reference is ON DELETE RESTRICT.

The public API returns a file as { id, name, url, mimeType, size, width, height }, never as a raw id. A write may only point at a file uploaded for this content type, uploaded by the signed-in user, or already on the record. The API refuses anything else with CONTENT_FILE_NOT_FOUND.

Relation

category: field.relation({ required: true, onDelete: "restrict", target: () => categoryContentType }),
NameTypeRequiredDefaultDescription
target() => ContentTypeDefinitionYes, unless selfThe linked content type
selftrueNofalseLinks to the same content type. Do not combine with target
multiplebooleanNofalseUses a junction table, up to 500 targets per record
orderedbooleanNofalseKeeps the editor's order. Needs multiple
minnumberNoFewest targets, 1 to 500. Needs multiple
onDelete"restrict" | "cascade" | "set null"No"restrict"What deleting the target does. "set null" needs a nullable single relation

A single relation must be required or nullable. A to-many relation can be neither. Relations shows the model wiring and the junction tables.

Group

syndication: field.group({
  fields: {
    noIndex: field.boolean({ defaultValue: false }),
    priority: field.number({ integer: true, min: 0, max: 10, defaultValue: 5 }),
  },
}),
NameTypeRequiredDefaultDescription
fieldsobject of leaf fieldsYestext, textarea, number, boolean, enum or dateTime leaves
localizedbooleanNofalseMoves every leaf to the translation table together

Each leaf becomes a column named <group><Leaf>, such as syndicationPriority. Leaves cannot set localized themselves. An optional group needs leaves that are nullable or have a default. A nullable group needs nullable leaves.

Repeatable

faq: field.repeatable({
  max: 20,
  fields: {
    question: field.text({ required: true, minLength: 3, maxLength: 200 }),
    answer: field.textarea({ required: true }),
  },
}),
NameTypeRequiredDefaultDescription
fieldsobject of leaf fieldsYesSame leaf kinds as a group
minnumberNo0Fewest rows
maxnumberNo100Most rows, up to 1000
descriptionstringNoShown in the AdminCP form

Rows live in a child table. A repeatable is always shared between languages.

Blocks

content: field.blocks({ allowed: ["core:*", "example:callout", "example:features"], max: 50 }),
NameTypeRequiredDefaultDescription
allowed"*" or string[]No"*"Which blocks may be placed, such as "core:*"
minnumberNoFewest block instances
maxnumberNo200Most block instances, up to 1000
localizedbooleanNofalseEach language gets its own blocks
descriptionstringNo

field.widgets() is the same helper under another name. A blocks field is never required or nullable. No blocks means an empty list. It does not appear in the AdminCP form. Editors place widgets on the page itself, as described in The blocks() field.

AI suggestions

text and textarea fields accept an ai option. The form then shows an AI button next to the label, like the excerpt in the screenshot above.

excerpt: field.textarea({
  ai: {
    action: "@vitnode/example:article.excerpt",
    mode: "suggestion",
    sourceFields: ["title", "code"],
  },
  maxLength: 500,
  nullable: true,
}),
NameTypeRequiredDefaultDescription
action"<pluginId>:<actionId>"YesA registered AI action
mode"suggestion"YesThe only supported mode. The editor reviews the text before it is used
sourceFieldsstring[]YesOther fields of this content type sent to the action as input

AI in fields and the editor shows how to write the action.

Searching and sorting

Fields have no searchable or sortable option. Each surface picks its own fields: admin.list.searchableFields and orderableFields for the AdminCP list, publicApi.searchableFields, orderableFields and filterableFields for the public API, and search.contentFields for site search. The content type reference lists which field kinds each one accepts.