Skip to content

Schemas

Schemas tell Studio which fields an entry has, their types, and their labels. They are optional: without one, CaretCMS infers a schema from stored data.

A schema lets Studio render typed controls such as enum menus, image controls, and validated number fields. Add one when the collection shape is stable.

Start with a registered schema: a field definition you keep in your Astro configuration. This complete example creates a clear Studio form for a pages collection without adding another package:

astro.config.mjs
import { defineConfig } from 'astro/config';
import caret, { filesystemStorage } from '@caretcms/core';
export default defineConfig({
integrations: [caret({
delivery: 'static',
storage: filesystemStorage(),
schemas: {
pages: {
type: 'object',
properties: {
headline: { type: 'string', title: 'Headline', minLength: 1 },
intro: { type: 'string', title: 'Introduction', format: 'textarea' },
image: { type: 'string', title: 'Hero image', format: 'image' },
alt: { type: 'string', title: 'Alternative text' },
},
required: ['headline'],
},
},
})],
});

Keep your existing delivery, storage, and other integrations if you already have a working configuration; the relevant addition is schemas.pages. Bind a field with data-caret="pages::home::headline", where home is the entry ID.

type selects the kind of value, title supplies its label, and required lists the fields that must exist. minLength: 1 also prevents an empty headline. A schema defines an entry’s shape; it does not create page routes or seed content.

Zod is optional. If your project already uses Zod 4, the companion package converts those definitions into the JSON Schema CaretCMS accepts:

Terminal window
npm install @caretcms/zod@0.5.0 zod@4

First, keep the shared definition in a plain module:

src/cms-schemas.ts
import { z } from 'zod';
export const PageSchema = z.object({
headline: z.string().min(1).meta({ title: 'Headline' }),
intro: z.string().meta({ title: 'Introduction', format: 'textarea' }),
image: z.string().meta({ title: 'Hero image', format: 'image' }),
alt: z.string().meta({ title: 'Alternative text' }),
});

Then import and convert it in your existing configuration:

import { schemaFromZod } from '@caretcms/zod';
import { PageSchema } from './src/cms-schemas';
// Inside your existing caret({...}) call:
// schemas: { pages: schemaFromZod(PageSchema) }

For several collections, schemasFromZod({ pages: PageSchema, site: SiteSchema }) converts a map of your definitions. Keep shared schemas independent of astro:content, so both src/content.config.ts and astro.config.mjs can import them. A schema in Astro’s content config is not automatically registered with CaretCMS; pass it through the schemas option too.

The helper promotes .describe() to a label when no explicit title is set. It converts z.date() into a string schema with format: 'date'; Studio still uses a text input, not a date picker. When validating date strings with Zod, choose a string schema or an appropriate coercion for your data source. CaretCMS enforces its supported JSON Schema subset, not arbitrary Zod refinements or transformation functions.

Studio uses the first available source for each collection:

Source, in priority order Where it comes from
Registered The schemas option in caret()
Dynamic Collection metadata created through the authenticated API
Inferred Field types guessed from the first stored entry

The schema endpoint reports these as explicit, dynamic, and inferred. Registering a schema overrides a dynamic one without deleting its entries. For most sites, start with registered schemas and use inference while prototyping.

Use registered schemas for fields you maintain in code. Use dynamic schemas when a custom application must define collections at runtime. Leave schemas unset for a quick prototype, then register them once the field structure is settled.

API-defined

Custom clients create collections with the create_collection mutation. Studio has no collection-builder screen in 0.5.0. After the API creates metadata, Studio can list the collection and edit its entries.

See Dynamic Collections for the full flow. The stored metadata looks like:

{
"id": "products",
"label": "Products",
"icon": "📦",
"creatable": true,
"orderable": true,
"deletable": true,
"order": 20,
"schema": {
"type": "object",
"properties": {
"name": { "type": "string", "title": "Product name" },
"price": { "type": "number", "minimum": 0, "title": "Price (USD)" },
"in_stock": { "type": "boolean", "title": "In stock" }
},
"required": ["name", "price"]
},
"created_at": 1714225200000,
"updated_at": 1714225200000
}

Use this when an authenticated custom client needs to define new content types without redeploying the integration. Public templates still need to render them.

If no schema is registered and no metadata exists, Studio reads the first entry in the collection and guesses field types from JS values:

JS value Inferred type
"hello" string
42 number
true boolean
["a", "b"] array of string
{ x: 1 } object with inferred properties

Inference is useful for trying an existing collection. An empty collection has no fields to infer; register a schema before asking editors to create its entries.

Use title for a short field label and description for a specific explanation. Add x-caret-groups to an object schema to group its immediate properties:

const pageSchema = {
type: 'object',
'x-caret-groups': [
{ title: 'Hero', description: 'The first content visitors see.', fields: ['headline', 'cover', 'alt'] },
{ title: 'Body', fields: ['body'] },
],
properties: {
headline: { type: 'string', title: 'Headline', minLength: 1 },
cover: { type: 'string', title: 'Cover image', format: 'image' },
alt: { type: 'string', title: 'Alternative text', description: 'Describe the image for someone who cannot see it.' },
body: { type: 'string', title: 'Body', format: 'html' },
},
required: ['headline'],
};
caret({ schemas: { pages: pageSchema } });

Ungrouped properties remain available in an additional section. Groups organize controls; they do not change the stored object shape or validation rules.

Format Studio control
image Image preview, replacement upload, secondary URL entry
html Sanitized formatted text with optional Edit HTML source mode
textarea Multiline text; multiline stored strings also use this control
date / date-time String input; no dedicated date picker
email / url / uri Suitable text input

Alternative text should be a visible schema field used by your template’s alt attribute. Do not rely on image replacement to invent meaningful alt text.

Every schema response includes a template — a default value for new entries. Studio uses it when an editor clicks “New entry”:

Field type Template value
string ""
number 0
boolean false
object {} with each property recursively templated
array []
enum First value

You can override per-field via default in JSON Schema (or .default(value) in Zod).

CaretCMS understands a focused subset of JSON Schema for Studio controls, templates, and dependency-free server validation. Extra metadata is preserved, but unsupported validation keywords are not enforced.

Feature Supported Notes
type: string Yes validates minLength, maxLength, and pattern
format: email / url Studio control chooses the input type; format itself is not server-validated
type: number / integer Yes validates minimum, maximum, exclusive bounds, and integer values
type: boolean Yes
type: array Yes validates items, minItems, and maxItems recursively
type: object Yes validates nested properties, required, and additionalProperties: false
enum, const Yes exact-value validation
required Yes array of property names
title, description Yes shown as labels and helper text
default Yes used in template generation
oneOf, anyOf Yes value must match at least one alternative
allOf Yes every child schema is applied
local $ref Yes #/... references only; remote references are not fetched

Validation failures return structured issues with path, code, and message so Studio can place an error beside the exact nested field. The server validates put_entry, field saves, and every affected entry in a reorder before any write is committed.