Schema Definition
CellCMS uses Sanity-compatible schema definitions, so you can reuse existing Sanity schemas with minimal changes. The @cellcms/schema package provides type-safe helper functions, a chainable validation API, and utilities for compiling and serializing schemas.
If you are migrating from Sanity, replace @sanity/types imports with @cellcms/schema and your schemas will work as-is.
Table of Contents
- Core API
- Field Types
- Validation Rules
- Field Options
- Preview Configuration
- Orderings
- Initial Values
- Compiling and Serializing
- Document Validation
- Complete Example
- Related Documentation
Core API
defineType(def)
Defines a top-level schema type. This is a pass-through function that provides TypeScript type checking and autocompletion for your type definition.
import { defineType } from '@cellcms/schema'
const post = defineType({
name: 'post',
title: 'Blog Post',
type: 'document',
description: 'A blog post with rich text content',
fields: [
// field definitions...
],
})
SchemaTypeDef properties:
| Property | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Unique identifier for the type |
title | string | No | Human-readable label shown in Studio |
type | SchemaType | Yes | The base type (see Field Types) |
fields | SchemaFieldDef[] | For document/object | Array of field definitions |
description | string | No | Help text shown below the title |
validation | (Rule) => Rule | No | Validation rules for the type |
preview | PreviewConfig | No | Configure list previews |
orderings | OrderingDef[] | No | Custom sort options for Studio |
initialValue | object | () => object | No | Default values for new documents |
icon | ComponentType | No | Icon component for the type |
defineField(def)
Defines a field within a type. Provides type safety for field configuration.
import { defineField } from '@cellcms/schema'
const titleField = defineField({
name: 'title',
title: 'Title',
type: 'string',
validation: (Rule) => Rule.required().max(200),
})
SchemaFieldDef properties:
| Property | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Field identifier (used in documents and queries) |
title | string | No | Human-readable label |
type | SchemaType | Yes | The field type |
description | string | No | Help text shown below the field |
validation | (Rule) => Rule | No | Validation rules |
fields | SchemaFieldDef[] | No | Sub-fields (for object type) |
of | ArrayMemberDef[] | No | Allowed member types (for array type) |
to | ReferenceDef[] | No | Allowed target types (for reference type) |
options | FieldOptions | No | Type-specific options |
hidden | boolean | (context) => boolean | No | Conditionally hide the field |
readOnly | boolean | (context) => boolean | No | Conditionally make read-only |
initialValue | any | () => any | No | Default value |
defineArrayMember(def)
Defines an allowed member type within an array field.
import { defineField, defineArrayMember } from '@cellcms/schema'
defineField({
name: 'tags',
title: 'Tags',
type: 'array',
of: [
defineArrayMember({ type: 'string' }),
defineArrayMember({ type: 'reference', to: [{ type: 'tag' }] }),
],
})
Field Types
CellCMS supports all standard Sanity field types. The type property accepts any of these values:
document | object | string | text | number | boolean | date | datetime | url | email | slug | image | file | reference | array | block | geopoint
string
A single-line text input.
defineField({
name: 'title',
title: 'Title',
type: 'string',
validation: (Rule) => Rule.required().min(5).max(200),
})
With a predefined list of values:
defineField({
name: 'status',
title: 'Status',
type: 'string',
options: {
list: [
{ title: 'Draft', value: 'draft' },
{ title: 'In Review', value: 'review' },
{ title: 'Published', value: 'published' },
],
layout: 'radio', // 'dropdown' (default) or 'radio'
},
initialValue: 'draft',
})
text
A multi-line text area for longer plain text content.
defineField({
name: 'excerpt',
title: 'Excerpt',
type: 'text',
validation: (Rule) => Rule.required().max(500),
})
number
A numeric input.
defineField({
name: 'price',
title: 'Price',
type: 'number',
validation: (Rule) => Rule.required().positive().min(0.01),
})
boolean
A toggle switch.
defineField({
name: 'featured',
title: 'Featured',
type: 'boolean',
initialValue: false,
})
date
A date picker (no time component). Stored as YYYY-MM-DD.
defineField({
name: 'birthDate',
title: 'Birth Date',
type: 'date',
})
datetime
A full date and time picker. Stored as an ISO 8601 string.
defineField({
name: 'publishedAt',
title: 'Published At',
type: 'datetime',
validation: (Rule) => Rule.required(),
})
url
A URL input with built-in format validation.
defineField({
name: 'website',
title: 'Website',
type: 'url',
validation: (Rule) =>
Rule.uri({
scheme: ['http', 'https'],
allowRelative: false,
}),
})
An email input with built-in format validation.
defineField({
name: 'contactEmail',
title: 'Contact Email',
type: 'email',
validation: (Rule) => Rule.required(),
})
slug
A URL-friendly slug, typically auto-generated from another field.
defineField({
name: 'slug',
title: 'Slug',
type: 'slug',
options: {
source: 'title',
maxLength: 96,
},
validation: (Rule) => Rule.required(),
})
The source option tells Studio which field to generate the slug from. The value is stored as { current: "the-slug-value" }.
image
An image field with optional hotspot support for responsive cropping.
defineField({
name: 'coverImage',
title: 'Cover Image',
type: 'image',
options: {
hotspot: true,
},
})
Images with hotspot: true store crop and hotspot coordinates alongside the asset reference, letting frontends crop intelligently around the focal point.
You can add custom fields to image objects:
defineField({
name: 'mainImage',
title: 'Main Image',
type: 'image',
options: { hotspot: true },
fields: [
defineField({
name: 'alt',
title: 'Alternative Text',
type: 'string',
validation: (Rule) => Rule.required(),
}),
defineField({
name: 'caption',
title: 'Caption',
type: 'string',
}),
],
})
file
A generic file upload field.
defineField({
name: 'attachment',
title: 'Attachment',
type: 'file',
options: {
accept: '.pdf,.docx,.xlsx',
},
})
The accept option restricts allowed file types using standard MIME type or extension patterns.
reference
A reference to another document. Use the to array to specify which document types can be referenced.
defineField({
name: 'author',
title: 'Author',
type: 'reference',
to: [{ type: 'author' }],
validation: (Rule) => Rule.required(),
})
References to multiple types:
defineField({
name: 'relatedContent',
title: 'Related Content',
type: 'reference',
to: [{ type: 'post' }, { type: 'page' }, { type: 'product' }],
})
References are stored as { _type: "reference", _ref: "document-id" }.
array
An ordered list of items. Use the of property to define allowed member types.
Array of strings:
defineField({
name: 'tags',
title: 'Tags',
type: 'array',
of: [defineArrayMember({ type: 'string' })],
validation: (Rule) => Rule.required().min(1).unique(),
})
Array of references:
defineField({
name: 'authors',
title: 'Authors',
type: 'array',
of: [defineArrayMember({ type: 'reference', to: [{ type: 'author' }] })],
options: {
sortable: true,
},
})
Array of objects (inline):
defineField({
name: 'socialLinks',
title: 'Social Links',
type: 'array',
of: [
defineArrayMember({
type: 'object',
name: 'socialLink',
fields: [
defineField({ name: 'platform', title: 'Platform', type: 'string' }),
defineField({ name: 'url', title: 'URL', type: 'url' }),
],
}),
],
options: {
layout: 'list', // 'list' (default) or 'grid'
modal: { type: 'dialog' }, // open items in a dialog
sortable: true,
},
})
Array layout options:
| Option | Type | Description |
|---|---|---|
layout | 'list' | 'grid' | Visual layout of array items |
sortable | boolean | Allow drag-to-reorder (default: true) |
modal | { type: 'dialog' | 'popover' } | How to open items for editing |
stacked | boolean | Stack items visually in the list |
object
An inline group of fields, useful for structured data that does not need its own document.
defineField({
name: 'address',
title: 'Address',
type: 'object',
fields: [
defineField({ name: 'street', title: 'Street', type: 'string' }),
defineField({ name: 'city', title: 'City', type: 'string' }),
defineField({ name: 'zipCode', title: 'ZIP Code', type: 'string' }),
defineField({ name: 'country', title: 'Country', type: 'string' }),
],
options: {
collapsible: true,
collapsed: false,
},
})
You can also define reusable object types at the top level:
const seo = defineType({
name: 'seo',
title: 'SEO',
type: 'object',
fields: [
defineField({ name: 'metaTitle', title: 'Meta Title', type: 'string' }),
defineField({
name: 'metaDescription',
title: 'Meta Description',
type: 'text',
}),
defineField({ name: 'ogImage', title: 'OG Image', type: 'image' }),
],
})
Then reference it in a document:
defineField({
name: 'seo',
title: 'SEO Settings',
type: 'seo', // references the named type above
})
block
Portable Text rich content editor. This is the primary rich text field type, producing structured content that can be rendered in any frontend framework.
defineField({
name: 'body',
title: 'Body',
type: 'array',
of: [
defineArrayMember({
type: 'block',
}),
defineArrayMember({ type: 'image', options: { hotspot: true } }),
],
})
For a simpler single-block field:
defineField({
name: 'content',
title: 'Content',
type: 'block',
})
geopoint
A geographic coordinate with latitude, longitude, and optional altitude.
defineField({
name: 'location',
title: 'Location',
type: 'geopoint',
})
Stored as { _type: "geopoint", lat: 52.3676, lng: 4.9041, alt: 0 }.
Validation Rules
Validation is defined through the validation property using a chainable Rule API. The callback receives a Rule instance and must return it.
validation: (Rule) => Rule.required().min(5).max(200)
Available Rules
| Rule | Applies To | Description |
|---|---|---|
required() | All types | Field must have a value |
min(n) | string, text, number, array | Minimum length or value |
max(n) | string, text, number, array | Maximum length or value |
length(n) | string, text, array | Exact length |
regex(pattern, options) | string, text | Must match a regular expression |
email() | string | Must be a valid email address |
uri(options) | string | Must be a valid URI |
integer() | number | Must be a whole number |
positive() | number | Must be greater than zero |
unique() | array | Array items must be unique |
custom(fn) | All types | Custom validation function |
required
Ensures the field has a value. Empty strings, null, and undefined all fail.
validation: (Rule) => Rule.required()
min / max
For strings and text, min and max validate character count. For numbers, they validate the numeric value. For arrays, they validate the number of items.
// String: between 5 and 200 characters
validation: (Rule) => Rule.min(5).max(200)
// Number: between 0 and 100
validation: (Rule) => Rule.min(0).max(100)
// Array: between 1 and 10 items
validation: (Rule) => Rule.min(1).max(10)
length
Validates an exact length for strings or arrays.
// Exactly 5 characters
validation: (Rule) => Rule.length(5)
regex
Tests the value against a regular expression pattern.
// Must start with a letter
validation: (Rule) => Rule.regex(/^[a-zA-Z]/, { name: 'Must start with a letter' })
// Hex color code
validation: (Rule) => Rule.regex(/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/, {
name: 'Hex color',
invert: false,
})
The options parameter accepts { name?: string, invert?: boolean }. When invert is true, the validation fails if the pattern matches.
Validates the value as an email address.
validation: (Rule) => Rule.email()
uri
Validates the value as a URI with configurable options.
validation: (Rule) => Rule.uri({
scheme: ['http', 'https', 'mailto'],
allowRelative: false,
allowCredentials: false,
})
URI options:
| Option | Type | Default | Description |
|---|---|---|---|
scheme | string[] | ['http', 'https'] | Allowed URI schemes |
allowRelative | boolean | false | Allow relative URIs |
allowCredentials | boolean | false | Allow user:pass@ in URI |
integer
Ensures a number value is a whole number (no decimals).
validation: (Rule) => Rule.required().integer().positive()
positive
Ensures a number value is greater than zero.
validation: (Rule) => Rule.positive()
unique
Ensures all items in an array are unique.
validation: (Rule) => Rule.unique()
custom
Write a custom validation function. Return true for valid, or a string error message for invalid. The function can be asynchronous.
validation: (Rule) =>
Rule.custom((value) => {
if (typeof value === 'string' && value.includes('forbidden')) {
return 'Value must not contain "forbidden"'
}
return true
})
Async example:
validation: (Rule) =>
Rule.custom(async (value, context) => {
// context provides { document, path, type }
if (!value) return true
const isUnique = await checkSlugUniqueness(value, context)
return isUnique || 'Slug is already in use'
})
Error and Warning Levels
By default, validation failures produce errors that block saving. You can downgrade to warnings using .warning(), or set a custom message with .error(msg) or .message(msg).
// Custom error message
validation: (Rule) => Rule.required().error('A title is required to publish')
// Warning instead of error (does not block saving)
validation: (Rule) =>
Rule.max(160).warning('Descriptions over 160 characters may be truncated in search results')
// Custom message (same as .error())
validation: (Rule) => Rule.min(10).message('Please write at least 10 characters')
Field Options
hidden
Conditionally hide a field based on document state or user context.
// Always hidden
defineField({
name: 'internalNotes',
type: 'text',
hidden: true,
})
// Conditionally hidden
defineField({
name: 'scheduledDate',
type: 'datetime',
hidden: ({ document }) => document?.status !== 'scheduled',
})
readOnly
Prevent editing of a field.
// Always read-only
defineField({
name: 'createdBy',
type: 'string',
readOnly: true,
})
// Conditionally read-only
defineField({
name: 'slug',
type: 'slug',
readOnly: ({ document }) => !!document?.publishedAt,
})
initialValue
Set default values for new documents or fields. Accepts a static value or a function.
// Static
defineField({
name: 'language',
type: 'string',
initialValue: 'en',
})
// Function (can be async)
defineField({
name: 'publishedAt',
type: 'datetime',
initialValue: () => new Date().toISOString(),
})
localize
Mark a field for localization. When enabled, the field stores values per locale instead of a single value.
defineField({
name: 'title',
title: 'Title',
type: 'string',
options: {
localize: true,
},
})
With localization, the stored value becomes an object keyed by locale: { en: "Hello", nl: "Hallo" }. See the Getting Started guide for project locale configuration.
list
Display a field as a dropdown or radio group instead of a free-form input.
defineField({
name: 'priority',
title: 'Priority',
type: 'number',
options: {
list: [
{ title: 'Low', value: 1 },
{ title: 'Medium', value: 2 },
{ title: 'High', value: 3 },
],
},
})
layout
Controls the visual presentation of a field. The available values depend on the field type:
- string/number with
list:'dropdown'(default) or'radio' - array:
'list'(default) or'grid' - object: supports
collapsibleandcollapsedoptions
defineField({
name: 'category',
type: 'string',
options: {
list: ['news', 'tutorial', 'review'],
layout: 'radio',
},
})
Preview Configuration
The preview property controls how documents and objects appear in lists and search results within Studio.
Basic Preview
Use select to map display fields:
defineType({
name: 'post',
type: 'document',
fields: [/* ... */],
preview: {
select: {
title: 'title',
subtitle: 'author.name',
media: 'coverImage',
},
},
})
The keys title, subtitle, and media are the three display slots. The values are dot-notation paths into the document.
Custom Preview with prepare
Use prepare when you need to transform or combine values:
defineType({
name: 'post',
type: 'document',
fields: [/* ... */],
preview: {
select: {
title: 'title',
authorName: 'author.name',
publishedAt: 'publishedAt',
media: 'coverImage',
},
prepare(selection) {
const { title, authorName, publishedAt, media } = selection
const date = publishedAt
? new Date(publishedAt).toLocaleDateString()
: 'Not published'
return {
title: title || 'Untitled',
subtitle: `${authorName || 'Unknown author'} - ${date}`,
media,
}
},
},
})
The prepare function receives an object with the selected values and must return { title, subtitle?, media? }.
Orderings
Define custom sort options for document lists in Studio. Users can switch between orderings in the document list view.
defineType({
name: 'post',
type: 'document',
fields: [/* ... */],
orderings: [
{
title: 'Publish Date (Newest)',
name: 'publishDateDesc',
by: [{ field: 'publishedAt', direction: 'desc' }],
},
{
title: 'Publish Date (Oldest)',
name: 'publishDateAsc',
by: [{ field: 'publishedAt', direction: 'asc' }],
},
{
title: 'Title A-Z',
name: 'titleAsc',
by: [{ field: 'title', direction: 'asc' }],
},
],
})
OrderingDef properties:
| Property | Type | Description |
|---|---|---|
title | string | Label shown in the ordering dropdown |
name | string | Unique identifier for this ordering |
by | Array<{ field, direction }> | Sort fields with 'asc' or 'desc' direction |
Multiple by entries create compound orderings (sort by first field, then by second on ties, etc.).
Initial Values
Set defaults for new documents using the initialValue property on a type definition. Accepts a static object or a function that returns one.
Static Initial Values
defineType({
name: 'post',
type: 'document',
initialValue: {
language: 'en',
featured: false,
status: 'draft',
},
fields: [/* ... */],
})
Dynamic Initial Values
Use a function when defaults depend on runtime context (e.g., current date, user info).
defineType({
name: 'post',
type: 'document',
initialValue: () => ({
publishedAt: new Date().toISOString(),
featured: false,
status: 'draft',
}),
fields: [/* ... */],
})
The function can also be async:
initialValue: async () => {
const defaultCategory = await fetchDefaultCategory()
return {
category: { _type: 'reference', _ref: defaultCategory._id },
}
}
Field-level initialValue takes precedence over type-level defaults for the same field.
Compiling and Serializing
The @cellcms/schema package provides functions for compiling type definitions into a validated schema and serializing it for the API.
compileSchema
Takes an array of type definitions and returns a CompiledSchema with resolved references, merged defaults, and validated structure.
import { compileSchema } from '@cellcms/schema'
import { post } from './schemas/post'
import { author } from './schemas/author'
import { category } from './schemas/category'
const schema = compileSchema([post, author, category])
compileSchema throws if type names conflict, required fields reference unknown types, or definitions are structurally invalid.
serializeSchema
Converts a compiled schema to a plain JSON representation suitable for sending to the CellCMS API.
import { compileSchema, serializeSchema } from '@cellcms/schema'
const schema = compileSchema([post, author, category])
const json = serializeSchema(schema)
// Send to the API or write to disk
console.log(JSON.stringify(json, null, 2))
Document Validation
Validate a document against a compiled schema at runtime.
import { compileSchema, validateDocument } from '@cellcms/schema'
const schema = compileSchema([post, author, category])
const document = {
_type: 'post',
title: '', // too short
slug: { current: 'hello-world' },
}
const errors = validateDocument(document, schema)
for (const error of errors) {
console.log(`${error.path.join('.')}: ${error.message}`)
// title: Value is required
}
validateDocument returns an array of ValidationError objects. Each error has:
| Property | Type | Description |
|---|---|---|
path | string[] | Path to the invalid field |
message | string | Human-readable error message |
level | 'error' | 'warning' | Severity level |
Complete Example
A full blog schema with post, author, and category types.
// schemas/author.ts
import { defineType, defineField } from '@cellcms/schema'
export const author = defineType({
name: 'author',
title: 'Author',
type: 'document',
fields: [
defineField({
name: 'name',
title: 'Name',
type: 'string',
validation: (Rule) => Rule.required(),
}),
defineField({
name: 'slug',
title: 'Slug',
type: 'slug',
options: { source: 'name', maxLength: 96 },
validation: (Rule) => Rule.required(),
}),
defineField({
name: 'email',
title: 'Email',
type: 'email',
}),
defineField({
name: 'avatar',
title: 'Avatar',
type: 'image',
options: { hotspot: true },
}),
defineField({
name: 'bio',
title: 'Bio',
type: 'text',
validation: (Rule) => Rule.max(500),
}),
],
preview: {
select: { title: 'name', media: 'avatar' },
},
})
// schemas/category.ts
import { defineType, defineField } from '@cellcms/schema'
export const category = defineType({
name: 'category',
title: 'Category',
type: 'document',
fields: [
defineField({
name: 'title',
title: 'Title',
type: 'string',
validation: (Rule) => Rule.required(),
}),
defineField({
name: 'slug',
title: 'Slug',
type: 'slug',
options: { source: 'title' },
validation: (Rule) => Rule.required(),
}),
defineField({
name: 'description',
title: 'Description',
type: 'text',
}),
defineField({
name: 'color',
title: 'Color',
type: 'string',
validation: (Rule) =>
Rule.regex(/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/, { name: 'Hex color' }),
}),
],
orderings: [
{
title: 'Title A-Z',
name: 'titleAsc',
by: [{ field: 'title', direction: 'asc' }],
},
],
preview: {
select: { title: 'title', subtitle: 'description' },
},
})
// schemas/post.ts
import { defineType, defineField, defineArrayMember } from '@cellcms/schema'
export const post = defineType({
name: 'post',
title: 'Blog Post',
type: 'document',
initialValue: () => ({
featured: false,
publishedAt: new Date().toISOString(),
}),
fields: [
defineField({
name: 'title',
title: 'Title',
type: 'string',
options: { localize: true },
validation: (Rule) => Rule.required().min(5).max(200),
}),
defineField({
name: 'slug',
title: 'Slug',
type: 'slug',
options: { source: 'title', maxLength: 96 },
validation: (Rule) => Rule.required(),
}),
defineField({
name: 'author',
title: 'Author',
type: 'reference',
to: [{ type: 'author' }],
validation: (Rule) => Rule.required(),
}),
defineField({
name: 'categories',
title: 'Categories',
type: 'array',
of: [defineArrayMember({ type: 'reference', to: [{ type: 'category' }] })],
validation: (Rule) => Rule.required().min(1).unique(),
}),
defineField({
name: 'coverImage',
title: 'Cover Image',
type: 'image',
options: { hotspot: true },
fields: [
defineField({
name: 'alt',
title: 'Alternative Text',
type: 'string',
validation: (Rule) => Rule.required().error('Alt text is required for accessibility'),
}),
],
}),
defineField({
name: 'excerpt',
title: 'Excerpt',
type: 'text',
options: { localize: true },
validation: (Rule) =>
Rule.max(300).warning('Excerpts over 300 characters may be truncated'),
}),
defineField({
name: 'body',
title: 'Body',
type: 'array',
of: [
defineArrayMember({ type: 'block' }),
defineArrayMember({
type: 'image',
options: { hotspot: true },
fields: [
defineField({ name: 'alt', title: 'Alt Text', type: 'string' }),
defineField({ name: 'caption', title: 'Caption', type: 'string' }),
],
}),
],
}),
defineField({
name: 'featured',
title: 'Featured',
type: 'boolean',
}),
defineField({
name: 'publishedAt',
title: 'Published At',
type: 'datetime',
validation: (Rule) => Rule.required(),
}),
defineField({
name: 'seo',
title: 'SEO',
type: 'object',
options: { collapsible: true, collapsed: true },
fields: [
defineField({
name: 'metaTitle',
title: 'Meta Title',
type: 'string',
validation: (Rule) => Rule.max(60),
}),
defineField({
name: 'metaDescription',
title: 'Meta Description',
type: 'text',
validation: (Rule) =>
Rule.max(160).warning('Search engines typically truncate after 160 characters'),
}),
],
}),
],
orderings: [
{
title: 'Publish Date (Newest)',
name: 'publishDateDesc',
by: [{ field: 'publishedAt', direction: 'desc' }],
},
{
title: 'Title A-Z',
name: 'titleAsc',
by: [{ field: 'title', direction: 'asc' }],
},
],
preview: {
select: {
title: 'title',
authorName: 'author.name',
media: 'coverImage',
publishedAt: 'publishedAt',
},
prepare(selection) {
const { title, authorName, publishedAt, media } = selection
return {
title: title || 'Untitled Post',
subtitle: [
authorName,
publishedAt && new Date(publishedAt).toLocaleDateString(),
]
.filter(Boolean)
.join(' - '),
media,
}
},
},
})
// schemas/index.ts
import { compileSchema } from '@cellcms/schema'
import { post } from './post'
import { author } from './author'
import { category } from './category'
export const schema = compileSchema([post, author, category])
Related Documentation
- Getting Started -- Installation and first document
- API Reference -- Complete REST API documentation
- Client SDK -- Frontend integration with
@cellcms/client - GROQ Reference -- Query language for fetching content
- Migrating from Sanity -- Switch from Sanity to CellCMS