Fields and widgets
How each Zod type in your schema becomes a control in the studio, and how hints, helpers and custom widgets change it.
Every field in a collection's schema becomes a control in the studio's editor. The type decides what's stored and how it's checked. A hint can choose a different control for the same type. Content files always hold plain values, so they stay readable without Vesper.
From type to control
| In your schema | In the studio |
|---|---|
z.string() | A one-line text field. z.email() and z.url() get email and web address fields. |
z.number() | A number field that follows min(), max(), int() and multipleOf() |
z.boolean() | A switch |
z.date() or z.coerce.date() | A date picker. The file keeps a calendar date, such as 2026-10-17. |
z.enum([...]) | Radio buttons for four options or fewer, otherwise a select |
z.array(z.string()) | A tag list: type a value and press Enter |
z.array(z.enum([...])) | A multi-select |
z.object({ ... }) | A group of fields under its own heading |
z.array(z.object({ ... })) | A repeater: add, remove and reorder items |
reference('authors') | A searchable list of that collection's entries. The file stores the entry's ID. |
z.array(reference('posts')) | An ordered list of entries, with Move up, Move down and Remove |
image() | An image picker. See Images. |
A few more rules:
.optional()and.default()make a field optional, and a default fills in new entries.- A union of string literals counts as an enum.
.refine()keeps the field's control. The refinement still runs when the entry is saved.- Fields appear in schema order. The first text field called
title,name,headingorlabelbecomes the page's heading in the editor.
Every save is checked against your schema. A problem shows under its field and in a summary at the top of the form.
Raw YAML fields
When Vesper has no control for a type, it shows the value as a small YAML text box marked "Edited as YAML", instead of hiding it or guessing. That happens for:
- unions of different types, such as
z.union([z.string(), z.object({ ... })]), discriminated unions included .transform()andz.preprocess(): the file holds something other than what the site reads, so editing the parsed value would write the wrong thing back- records, tuples,
z.any(), recursive schemas, and literals that aren't strings - a schema that isn't a
z.object()at the top level, whose whole frontmatter is then edited as YAML
The text is read the way Astro reads it, and what you type is kept while it's invalid. If a field shows as YAML and you'd rather it didn't, a simpler type usually gives it a real control.
Hints with .meta()
summary: z.string().meta({
widget: 'textarea',
description: 'One or two sentences for search results and link previews.',
}),| Key | What it does |
|---|---|
label | The field's label. Without one, Vesper writes the key in sentence case: publishedAt becomes "Published at", seoTitle "SEO title". |
description | Help text under the label |
widget | The control to use instead of the one the type implies |
widgetOptions | Settings for that control, such as { labels } for an enum's options |
placeholder | Sample text in an empty field. It's never a label. |
placement | 'main' or 'details': see below |
hidden | Leaves the field out of the editor. Saves don't change its value. |
readOnly | Shows the value, but editors can't change it |
autocomplete | The browser's autofill hint, such as email |
.meta() works before or after .optional() and .default(). Values must be plain data: a function in widgetOptions is dropped, with a warning.
The built-in widgets you can name are text, textarea, markdown, email, url, slug, color, file, number, switch, date, datetime, select, radio, tags, multiselect, group, repeater, reference, references, image, geo and raw. A name that doesn't exist, or a widget that can't edit the field's type (color on a number), falls back to the control the type implies. Vesper warns in the terminal, naming the field and the widget.
Field helpers
@vesper/astro/fields has helpers for common fields. Each returns plain Zod from astro/zod with .meta() attached, so Astro checks it like any other schema. Paste the raw Zod instead and you get the same field: a test keeps the two equal.
import { z } from 'astro/zod';
import { fields } from '@vesper/astro/fields';
const schema = z.object({
brandColor: fields.color({ palette: { Indigo: '#28335f', Pine: '#2f5d50' } }),
website: fields.url(),
startsAt: fields.datetime(),
});| Helper | Raw Zod | Stored as |
|---|---|---|
fields.color({ palette?, alpha?, contrast? }) | z.string().regex(/^#[0-9a-f]{6}$/i).meta({ widget: 'color' }) | "#1a73e8" |
fields.geo({ defaultCenter?, zoom? }) | z.object({ lat: z.number().min(-90).max(90), lng: z.number().min(-180).max(180) }).meta({ widget: 'geo' }) | { lat, lng } |
fields.slug({ from }) | z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/).meta({ widget: 'slug', widgetOptions: { from } }) | "my-post" |
fields.url() | z.url().meta({ widget: 'url' }) | "https://…" |
fields.email() | z.email().meta({ widget: 'email', autocomplete: 'email' }) | "a@b.com" |
fields.datetime() | z.coerce.date().meta({ widget: 'datetime' }) | ISO 8601, with an offset |
fields.select(options) | z.enum([...]).meta({ widgetOptions: { labels } }) | "value" |
fields.markdown({ inline? }) | z.string().meta({ widget: 'markdown' }) | A Markdown string |
fields.file({ accept }) | z.string().meta({ widget: 'file', widgetOptions: { accept } }) | A path, such as /files/brochure.pdf |
fields.image(image, { ratios? }) | See Images | { src, alt, … } |
fields.hideFromSearch() | z.boolean().default(false).meta({ label: 'Hide from site search', description, placement: 'details', search: 'hide' }) | true or false |
Options you pass go into widgetOptions, as they would in the raw form. What they do:
color()with apaletteshows the named swatches as radio buttons instead of a colour picker.alphaallows eight digits.contrast: 'text'(or'large-text','graphic') checks the colour against your declared backgrounds: see Accessibility.slug({ from: 'title' })follows that field on a new entry until someone edits the slug.geo()shows latitude and longitude fields. A map loads only when an editor asks for it with "Show on a map", anddefaultCenterandzoomset where it starts.datetime()is entered in the site's time zone (timeZoneinvesper.config.ts, otherwise each editor's own) and stored with that day's offset.markdown()is a small editor for bold, italic, links, paragraphs and lists. Withinline: trueit leaves out paragraphs and lists.select()takes a list of values, an object of values and labels ({ news: 'News' }), or[{ value, label }]. The file stores the value.hideFromSearch()keeps the entry's page out of the site's own search: see Site search.
Main column and Details
The editor shows the title as the page's heading, then the main fields. Secondary fields go to Details, at the end of the form. On wide screens, a Details button in the top bar opens them in a drawer instead. Details gets:
- slugs, dates and tags
- ordering and layout fields, such as
order,weight,layoutandtemplate draft, SEO fields such asseo,seoTitleandmetaDescription, andcanonical,noindexandaliases
To move a field, set .meta({ placement: 'main' }) or 'details' on it, or set it per collection in vesper.config.ts, which wins:
// vesper.config.ts
collections: {
events: { placement: { startsAt: 'main', endsAt: 'main' } },
},Custom widgets
When no built-in control fits, write your own as a React component and register it in vesper.config.ts:
import { defineConfig } from '@vesper/astro/config';
export default defineConfig({
widgets: {
'store-hours': {
component: () => import('./src/cms/StoreHours.tsx'),
accepts: (field) => field.kind === 'array',
match: (field) => field.path.at(-1) === 'hours',
},
},
});- A field uses the widget when its
.meta({ widget: 'store-hours' })names it, or whenmatchreturns true for it.acceptslimits which fields it can edit. - Vesper picks a control in this order: the widget a field names, then a custom widget whose
matchclaims it, then the control its type implies, then a raw YAML field. - The file's default export is the component. It receives
WidgetProps(import type { WidgetProps } from '@vesper/astro/config'):value,onChange,field,id,describedBy,invalidanddisabled. - The editor draws the label, help text and errors. Your control takes
id, passesdescribedByandinvalidon toaria-describedbyandaria-invalid, works with the keyboard, and never validates: the schema does. - Vesper compiles widget files as React, so your site needs no
@astrojs/react. Start each file with/** @jsxImportSource react */so TypeScript checks it as React. - If a widget fails to load, the field falls back to its built-in control, with a note.
Collections in the studio
vesper.config.ts also says how each collection appears:
collections: {
blog: { label: 'Blog posts', icon: 'newspaper', listFields: ['title', 'publishedAt'] },
settings: { label: 'Site settings', singleton: true, icon: 'settings' },
},labelis the collection's name in the studio. Without one, it's the collection's key in sentence case.singleton: trueis for a collection with one entry, such as site settings. Home lists it on its own, and it opens straight into its editor.listFieldsnames the fields the entry list shows beside each title.iconnames a Lucide icon. Install thelucidepackage to use one.
Every option is on vesper() and vesper.config.ts.