Skip to content

Schemas

A schema is a plain TypeScript object describing one content type. It is not a class, not a builder chain, and not a file format. You export it, you pass it to the server, and the same object drives storage validation, the Studio’s editing form, and the types your frontend compiles against.

schemas/post.ts
export const postSchema = {
name: 'post',
title: 'Post',
type: 'document',
description: 'Blog posts and announcements',
fields: {
title: { type: 'string', title: 'Title', required: true },
excerpt: { type: 'text', title: 'Excerpt' },
body: { type: 'richtext', title: 'Body' },
coverImage: { type: 'media', title: 'Cover image' },
publishedAt: { type: 'date', title: 'Published at', options: { includeTime: true } },
},
}

Four keys matter at the top level.

KeyRequiredWhat it does
nameyesThe collection name. It is the URL segment in /api/collections/post and the key everything else uses to find this schema.
typeyesEither 'document' or 'singleton'. Any other string is rejected at startup.
titlenoThe label the Studio shows. Falls back to the name.
descriptionnoShown in the Studio.
fieldsyesThe content model. See below.
singletonnotrue if the collection holds exactly one document. See Singletons.

type and singleton look like they overlap. They do not. Every code path that changes behaviour for a one-of-a-kind collection — the create guard, the PUT upsert, trokky restore — reads singleton === true. Writing type: 'singleton' and nothing else gives you a normal list collection with an unusual type string. Singletons covers the consequences; read it before you ship a homepage.

Unknown keys at the top level of a schema are stripped without a warning. The schema object is parsed against a fixed shape, so a typo like Fields or isSingleton produces no error, no log line, and no effect. This has cost a production site — the detail is in Traps.

fields is a record keyed by field name. The key becomes the property name on the stored document and on the generated TypeScript interface.

fields: {
title: { type: 'string', title: 'Title', required: true },
}

Every field needs a type that a registered field plugin claims. Field types lists all of them. A type nothing has registered is accepted by the schema parser — type is validated as “a string”, not as “one of these strings” — and then falls through to no validation on the server and no editor in the Studio.

The array form is also accepted and converted to the record form on load:

fields: [
{ name: 'title', type: 'string', title: 'Title', required: true },
{ name: 'body', type: 'richtext', title: 'Body' },
]

Each entry needs a non-empty name, and duplicate names throw at startup. The record form is what the engine works with, so prefer writing it directly.

Unlike the top level, unknown keys on a field are preserved. Field definitions are parsed with passthrough, which is how field-specific keys like source, to, of and prefix survive. It is also why a misspelled option on a field is silently carried along and then ignored by the editor that was supposed to read it.

required: true makes the field mandatory. required defaults to false.

The catch is where it is enforced. The server builds a validator from your schema, and it only knows nine field types: string, number, boolean, date, array, object, reference, media, slug. Every other type maps to “accept anything”, and “anything” includes absent.

So this is enforced by the API:

title: { type: 'string', title: 'Title', required: true },

and this is not:

body: { type: 'richtext', title: 'Body', required: true },

A POST with no body at all is accepted and stored. The Studio still marks the field as required and complains in the form, because the field plugin runs its own validation in the browser. The rule to carry: for types outside that list of nine, required is an editor rule, not a storage rule.

One more edge inside the list. A media field accepts null even when required, because clearing an image has to be expressible. required on a media field stops the Studio from saving, not the API.

A field can carry a validation object:

title: {
type: 'string',
title: 'Title',
required: true,
validation: { maxLength: 100 },
},

maxLength here is enforced by the string field plugin in the Studio. The server’s validator checks the type, and required, and for slugs a maximum length — it does not read minLength, maxLength, pattern, min, max, minItems, maxItems or any of the other per-type rules.

That is a deliberate split, and it has a consequence worth stating plainly: a script writing to the HTTP API can store values your editors could not enter. If a constraint has to hold for every writer, enforce it in the code that writes, or check it on read. Treat validation as the contract with the Studio.

The types each plugin accepts under validation are listed per type in Field types.

default seeds a new document in the Studio:

featured: { type: 'boolean', title: 'Featured', default: false },
publishedAt: { type: 'date', title: 'Published at', default: 'now' },

When an editor clicks “create”, the Studio copies these into the blank form. 'now' is a recognised sentinel on date fields and resolves to the current time; every other value is used verbatim.

Defaults are not applied by the API. Create a document with POST /api/collections/post and omit featured, and the stored document has no featured property — not false. The one exception is a singleton auto-created on first read, which the server builds from the schema’s declared defaults.

Schemas are checked once, at startup, when the registry loads them. A schema that fails throws immediately:

Invalid schema "post": ZodError: ...

The server does not start. That is the intended failure mode — a broken content model should not reach an editor.

Two fields are injected into your schema before it is registered. Both are on by default.

A slug field. If a schema has no slug field and has a string field to generate one from, one is added:

slug: {
type: 'slug',
source: 'title', // 'title', else 'name', else the first string field
autoGenerate: true,
unique: true,
required: false,
}

A thumbnail field. A media field named _thumbnail restricted to images is added, unless the schema already has a field called _thumbnail, thumbnail, featuredImage, featured_image or image. Singletons are skipped.

Both appear in the Studio, in stored documents, and in your generated types. If you did not expect them, that is where they came from. Turn either off in your config:

features: {
autoThumbnail: { enabled: false },
autoSlug: { enabled: false },
}

autoThumbnail also takes fieldName, skipSingletons, skipSchemas, maxFileSize and allowedTypes. Declaring your own slug field, or any of the image field names above, suppresses the corresponding injection for that schema without touching the config.

You import them and hand them to the config as an array:

schemas/index.ts
import { postSchema } from './post'
import { authorSchema } from './author'
export const schemas = [postSchema, authorSchema]
trokky.config.ts
import { schemas } from './schemas'
export default {
schemas,
storage: { /* ... */ },
}
server.ts
import express from 'express'
import { TrokkyExpress } from '@trokky/trokky/express'
import '@trokky/trokky/adapters/filesystem-data'
import '@trokky/trokky/adapters/filesystem-media'
import config from './trokky.config'
const app = express()
const trokky = await TrokkyExpress.create(config)
trokky.mount(app)
app.listen(3000)

There is no schema file discovery. schemas is typed as string | ContentSchema[], but passing a string throws File-based schema loading not yet implemented. Pass the array.

Once running, the server serves its registered schemas — after injection, after validation — from GET /api/collections. That endpoint is the single source everything downstream reads: the Studio builds its forms from it, and trokky generate-types builds your TypeScript interfaces from it.

This is why generated types can include a slug and a _thumbnail you never wrote. The generator is describing what the server has, not what your file says.

Editing a schema changes validation, the editor and the generated types on the next start. It does not touch documents already in storage.

Rename a field and the old key stays in every stored document, unread. Add a required field and existing documents keep validating on read — they are only checked on write, so the failure surfaces the next time someone saves one. Remove a field and its data stays on disk until something rewrites the document.

Plan the migration; the schema will not do it for you.

  • Field types — every type, what it stores, and what your frontend receives.
  • References — pointing at other documents, and what expansion costs.
  • Singletons — the singleton flag and what goes wrong without it.
  • Generated types — turning the registered schema into TypeScript.