Skip to content

Built-in types

Every field shares these keys:

{
name: string; // the object key. No dots
label: string; // shown above the control
help?: string; // shown under it
tab?: string; // which tab of a multi-tab form it belongs to
ai?: FieldAI; // the copy assistant's descriptor
}
type Stores Schema to pair with AI-editable
text string text, required yes
url string link, requiredLink no
email string text.email() no
tel string text no
textarea string text yes
richtext string of HTML text yes
image string URL link yes
avatar string URL link no
select string z.enum([...]) yes
toggle boolean toggle, enabled yes
stringlist string[] z.array(required) yes
group object z.object({...}) yes
list object[] z.array(z.object({...})) yes
seoPreview nothing none no

No options. url validates on the server through the link schema, which accepts https://, mailto:, tel:, a leading / and a leading #, and rejects everything else including javascript:.

{ name: 'intro', label: 'Intro', type: 'textarea', rows: 3 }

rows defaults to the browser’s default.

{ name: 'body', label: 'Body', type: 'richtext' }

Sanitised on every save, both from the form and from any AI proposal. The toolbar’s custom styles come from editorStyles in your site config:

editorStyles: [
{ className: 'block', display: 'block' },
{ className: 'text-yellow-400', label: 'Brand', color: '#fbbf24' },
]

A style with no label is applied but not offered as a button.

{ name: 'socialImage', label: 'Sharing image', type: 'image', crop: { width: 1200, height: 630 } }

With crop, the upload opens the cropper at that exact size and the stored image is the cropped result. Without it, the file is stored as uploaded. OG_CROP is exported as { width: 1200, height: 630 }.

{ name: 'photo', label: 'Photo', type: 'avatar', source: 'sourcePhoto' }

source names a sibling schema key holding the uncropped original, so a face can be re-cropped later without re-uploading. Add sourcePhoto: text to the schema and stop there. Do not add a second entry to fields: the avatar control renders its own hidden input under that name, and a visible one beside it is a duplicate. Leave the key out of the schema and the crop original is never stored, so the next edit starts from the cropped image.

Avatars crop to a 512 by 512 circle.

{
name: 'level',
label: 'Level',
type: 'select',
options: [
{ value: 'l1', label: 'Level 1' },
{ value: 'l2', label: 'Level 2' },
],
}

Pair with z.enum(['l1', 'l2']). The form does not enforce the option list; the schema does.

{ name: 'enabled', label: 'Published', type: 'toggle' }

Pair with toggle or enabled, never a plain z.boolean(). An unchecked checkbox submits the string 'false', and the helper preprocesses it. enabledField is a ready-made definition for the common case.

{ name: 'keywords', label: 'Keywords', type: 'stringlist' }

A repeater of plain text rows. Stored as string[].

{
name: 'button',
label: 'Call to action',
type: 'group',
fields: [
{ name: 'label', label: 'Label', type: 'text' },
{ name: 'link', label: 'Link', type: 'url' },
],
}

One nested object, rendered as a fieldset. Pair with z.object, or use the cta and linkSchema helpers.

{
name: 'people',
label: 'Coaches',
type: 'list',
singular: 'coach',
titleField: 'name',
fields: [
{ name: 'name', label: 'Name', type: 'text' },
{ name: 'role', label: 'Role', type: 'text' },
enabledField,
],
}

singular fills the “Add coach” button. titleField names each row in the collapsed header and in every history diff, so choose a field that is actually filled in.

Rows drag to reorder. Pair with z.array(z.object({...})).

{
name: 'socialPreview',
label: 'How this looks when shared',
type: 'seoPreview',
show: 'social',
watch: {
title: 'seo.title',
description: 'seo.description',
shareTitle: 'openGraph.title',
shareDescription: 'openGraph.description',
image: 'openGraph.image',
},
}

Stores nothing. It renders a live preview of a search result or a social card from other fields in the same form.

show is 'search', 'social' or 'both'. watch maps each slot to the input name of the field feeding it.

Point watch at a live input wherever the value is in the same form. A server supplied fallback for the same slot is never empty, so it shadows the input it was meant to back up and the preview stops updating as you type. Use a server fallback only for a value that genuinely is not on the page, such as a site default behind a per-page field.

See Field types.