Defining Content Models
This guide covers how to define content models with @verevoir/schema. A content model is a TypeScript definition that describes the shape of your content, provides validation, and gives editors enough metadata to render the right controls.
Installation
npm install @verevoir/schema
The only runtime dependency is zod.
Blocks and Fields
A block is a named content type. An article, an author, a settings page — each is a block. A block is composed of fields that describe the individual pieces of data.
import { defineBlock, text, richText, boolean, select } from '@verevoir/schema';
const article = defineBlock({
name: 'article',
fields: {
title: text('Title').max(120),
body: richText('Body'),
published: boolean('Published').default(false),
status: select('Status', ['draft', 'published', 'archived']),
},
});
The name identifies the block type in storage. Field keys become the property names in validated data.
Field Types
text(label)
Single-line string. Returns a StringField with chainable modifiers.
text('Title') // required string
text('Title').max(120) // max 120 characters
text('Title').min(3) // min 3 characters
text('Slug').regex(/^[a-z-]+$/) // must match pattern
text('Subtitle').optional() // not required
richText(label)
Multi-line string intended for rich content (HTML, Markdown, JSON). Same chainable modifiers as text(). The editor renders a textarea by default — override it for a custom rich text editor.
richText('Body')
richText('Bio').optional()
number(label)
Numeric value. Returns a NumberField with chainable modifiers.
number('Sort Order') // any number
number('Count').int() // integers only
number('Rating').min(1).max(5) // bounded range
number('Posts Per Page').int().min(1).max(100).default(10)
boolean(label)
True/false toggle. No extra modifiers beyond .optional() and .default().
boolean('Featured')
boolean('Maintenance Mode').default(false)
select(label, options)
Fixed set of string options. Validated as an enum.
select('Status', ['draft', 'published', 'archived'])
select('Role', ['author', 'editor', 'admin'])
reference(label, targetBlockType)
A UUID string pointing to another document. The targetBlockType tells the editor which block type to show options for.
import { reference } from '@verevoir/schema';
reference('Author', 'author') // single reference
reference('Category', 'category')
The stored value is a UUID string. Validation ensures it's a valid UUID format. The editor renders a select dropdown populated from a ReferenceOptionsProvider context.
array(label, itemField)
A list of items. Pass any field as the item template.
import { array } from '@verevoir/schema';
array('Tags', text('Tag')) // array of strings
array('Reviewers', reference('Reviewer', 'author')) // array of references
array('Scores', number('Score').int()) // array of numbers
Array metadata includes itemMeta — the metadata of the item field — so the editor knows how to render each item (e.g. a reference picker vs a text input).
object(label, fields)
A nested group of fields. Useful for structured sub-data.
import { object } from '@verevoir/schema';
object('SEO', {
metaTitle: text('Meta Title').max(60),
metaDescription: text('Meta Description').max(160).optional(),
})
Modifiers
All fields support:
| Modifier | Effect |
|---|---|
.optional() | Field is not required. Accepts undefined. |
.default(value) | Provides a default value. Field becomes not required. |
String fields additionally support .max(n), .min(n), .regex(pattern), .hint(directive).
Number fields additionally support .max(n), .min(n), .int().
.hint(directive)
Attach a natural-language directive to a text or rich-text field. The hint is stored in field metadata (field.meta.hint) and used by AI-assisted editing features — see the AI-Assisted Editing guide.
richText('Bio')
.hint('Third person, 2-3 sentences. Mention role, expertise, and one notable achievement.')
.optional()
text('Tagline')
.hint('Punchy, under 10 words. Active voice.')
.max(80)
Hints have no effect on validation — they are purely metadata. Fields without a hint work exactly as before.
Chaining
Modifiers are chainable and return new field instances (immutable):
text('Title').min(1).max(120) // chained
number('Rating').int().min(1).max(5) // chained
richText('Bio').hint('Third person').optional() // chained
Validation
Every block has a validate() method that parses unknown data and returns the typed result:
const data = article.validate({
title: 'Hello World',
body: '<p>Content</p>',
status: 'draft',
});
// data is typed: { title: string, body: string, published: boolean, status: 'draft' | 'published' | 'archived' }
Invalid data throws a ZodError with detailed messages per field:
try {
article.validate({ title: 123 });
} catch (err) {
// err.issues contains per-field validation errors
}
Type Inference
Extract the TypeScript type from a block definition:
import type { InferBlock } from '@verevoir/schema';
type Article = InferBlock<typeof article>;
// { title: string; body: string; published: boolean; status: 'draft' | 'published' | 'archived' }
This gives you full type safety without manually defining interfaces.
Real-World Example
import {
defineBlock,
text,
richText,
number,
boolean,
select,
reference,
array,
object,
} from '@verevoir/schema';
export const article = defineBlock({
name: 'article',
fields: {
title: text('Title').max(120),
slug: text('Slug').regex(/^[a-z0-9-]+$/),
author: reference('Author', 'author'),
body: richText('Body'),
excerpt: text('Excerpt').max(300).optional(),
tags: array('Tags', text('Tag')),
status: select('Status', ['draft', 'published', 'archived']),
featured: boolean('Featured').default(false),
seo: object('SEO', {
metaTitle: text('Meta Title').max(60).optional(),
metaDescription: text('Meta Description').max(160).optional(),
}),
},
});
export const author = defineBlock({
name: 'author',
fields: {
name: text('Name').max(100),
email: text('Email').regex(/^[\w.-]+@[\w.-]+\.\w+$/),
bio: richText('Bio').optional(),
role: select('Role', ['author', 'editor', 'admin']),
},
});
Using the Raw Zod Schema
Every field exposes its underlying Zod schema at .schema. Every block exposes its composed schema at .schema. You can use these directly with Zod's API:
// Use safeParse instead of throwing
const result = article.schema.safeParse(data);
if (!result.success) {
console.log(result.error.issues);
}
// Extract a single field's schema
const titleSchema = article.fields.title.schema;
titleSchema.parse('Hello'); // works
Field Metadata
Every field carries metadata at .meta:
article.fields.title.meta
// { label: 'Title', ui: 'text', required: true }
article.fields.author.meta
// { label: 'Author', ui: 'reference', required: true, targetBlockType: 'author' }
article.fields.tags.meta
// { label: 'Tags', ui: 'array', required: true, itemMeta: { label: 'Tag', ui: 'text', required: true } }
The ui hint tells the editor which control to render. The targetBlockType and itemMeta properties carry extra context for reference and array fields. The hint property (when set via .hint()) carries the AI directive for copy generation.
Next Steps
- Integration Guide — connecting content models, storage, and editor in an application
- AI-Assisted Editing — using
.hint()with LLM-powered copy generation