Core Concepts

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

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:

PropertyTypeRequiredDescription
namestringYesUnique identifier for the type
titlestringNoHuman-readable label shown in Studio
typeSchemaTypeYesThe base type (see Field Types)
fieldsSchemaFieldDef[]For document/objectArray of field definitions
descriptionstringNoHelp text shown below the title
validation(Rule) => RuleNoValidation rules for the type
previewPreviewConfigNoConfigure list previews
orderingsOrderingDef[]NoCustom sort options for Studio
initialValueobject | () => objectNoDefault values for new documents
iconComponentTypeNoIcon 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:

PropertyTypeRequiredDescription
namestringYesField identifier (used in documents and queries)
titlestringNoHuman-readable label
typeSchemaTypeYesThe field type
descriptionstringNoHelp text shown below the field
validation(Rule) => RuleNoValidation rules
fieldsSchemaFieldDef[]NoSub-fields (for object type)
ofArrayMemberDef[]NoAllowed member types (for array type)
toReferenceDef[]NoAllowed target types (for reference type)
optionsFieldOptionsNoType-specific options
hiddenboolean | (context) => booleanNoConditionally hide the field
readOnlyboolean | (context) => booleanNoConditionally make read-only
initialValueany | () => anyNoDefault 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,
    }),
})

email

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:

OptionTypeDescription
layout'list' | 'grid'Visual layout of array items
sortablebooleanAllow drag-to-reorder (default: true)
modal{ type: 'dialog' | 'popover' }How to open items for editing
stackedbooleanStack 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

RuleApplies ToDescription
required()All typesField must have a value
min(n)string, text, number, arrayMinimum length or value
max(n)string, text, number, arrayMaximum length or value
length(n)string, text, arrayExact length
regex(pattern, options)string, textMust match a regular expression
email()stringMust be a valid email address
uri(options)stringMust be a valid URI
integer()numberMust be a whole number
positive()numberMust be greater than zero
unique()arrayArray items must be unique
custom(fn)All typesCustom 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.

email

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:

OptionTypeDefaultDescription
schemestring[]['http', 'https']Allowed URI schemes
allowRelativebooleanfalseAllow relative URIs
allowCredentialsbooleanfalseAllow 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 collapsible and collapsed options
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:

PropertyTypeDescription
titlestringLabel shown in the ordering dropdown
namestringUnique identifier for this ordering
byArray<{ 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:

PropertyTypeDescription
pathstring[]Path to the invalid field
messagestringHuman-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])