Schema

Describe your content once, and get types everywhere from it.

Content5 min read

The schema says what fields each kind of content has: a page has a title and an optional tagline, a product has a name and a price. You write it once, in graft.config.ts, and Graft uses it in three places:

  • graft compile checks every file against it, and stops with a clear error when one does not match.
  • Typed functions check their inputs against it.
  • Every read in your app is typed from it, so your editor knows each field.

It is built on Zod, a TypeScript validation library, so there is no code generation step. This page declares a collection, shows the file and the typed read that go with it, and lists every field type.

Declare a collection

A collection is a group of documents with the same fields, stored in content/<collection name>/:

graft.config.ts
import { defineCollection, field } from "@usegraft/core";

export const pages = defineCollection({
  name: "pages",
  description: "Marketing and site pages.",
  fields: {
    title: field.string({ maxLength: 120, description: "Headline and <title>." }),
    tagline: field.string({ optional: true }),
    image: field.asset({ optional: true }),
  },
});

The descriptions are for agents. An agent that calls describe_schema reads them to learn what each collection and field is for, so write them as you would explain the field to a new teammate.

What a matching file looks like

Each document is a file whose frontmatter matches the fields:

content/pages/about.mdx
---
title: About us
tagline: We build with code and AI agents.
---

The body is Markdown or MDX.

And your app reads it with full types:

const about = await graft.getContent("pages", "about");
about?.data.title;   // string
about?.data.tagline; // string | undefined
about?.data.color;   // TypeScript error: pages has no field "color"

What happens when a file does not match

If a file is missing a required field, or a value has the wrong type or is too long, graft compile stops with SCHEMA_VALIDATION_FAILED. The message names the file and the field, so you know exactly what to fix. Nothing is written to the index until every file passes.

Choose a field type

BuilderHoldsUse it for
field.string()A short stringTitles, names, slugs
field.text()A long stringSummaries and longer text
field.number()A numberPrices, quantities, ratings
field.boolean()true or falseFlags such as featured
field.datetime()An ISO date and timePublish dates
field.asset(){ key, alt? }Images and files. See Assets
field.select({ options })One value from a fixed listA status, a section, a size
field.reference({ to })The slug of a document in another collectionA product's category, a post's author
field.object({ fields })A nested group of fieldsAn author with a name and a URL
field.array({ of })A list of another fieldTags, FAQ entries
field.json()Any JSON valueA last resort. Prefer an object or array, which are checked and typed

Every builder accepts optional: true and a description.

Limit what a field accepts

OptionApplies toEffect
maxLengthstring, textThe longest allowed value
patternstring, textA regular expression the value must match
min, maxnumberThe allowed range
int: truenumberWhole numbers only
maxItemsarrayThe most entries allowed
format: "money"numberAn amount in the currency's minor unit (cents for USD, whole yen for JPY). Implies int. Studio shows and edits it as money
currencynumber with format: "money"The ISO currency code Studio shows. Defaults to USD
labelevery fieldWhat Studio calls the field, such as "Price". The key stays the same

Set maxLength on any string or text field a public form writes to, and maxItems on any array, so one request cannot store megabytes. Set max on numbers that get multiplied, such as a quantity times a price. field.json() has no size limit, so keep it out of public forms.

Choices and references

field.select limits a value to a list. Each option is a string, or a value with the label people see:

status: field.select({
  options: ["draft", { value: "active", label: "On sale" }, "archived"],
}),

field.reference stores the slug of a document in another collection. The file stays readable (category: summer-sale), Studio offers a picker over the target collection, and describe_schema tells an agent where to look the value up:

category: field.reference({ to: "categories" }),

The slug has to name a document that exists. graft compile stops with CONTENT_REFERENCE_NOT_FOUND when a reference points at nothing, and names the file, the field and the missing slug. In a database-sourced project, publishing refuses the same thing, and also refuses to publish the deletion of a document that live documents still point at. Publish the documents that point at it in the same step, changed to point elsewhere.

A local Studio and write_content check the same thing before they write, so a refused save, bulk edit, delete or discard leaves every file as it was. A save is checked against the documents in the index, or a file in the content folder the index hasn't caught up with, such as one a git pull brought in. A reference that was already broken somewhere else doesn't stop an unrelated save; graft compile still reports it.

A reference into a collection kept in the database (db-authoritative) is not checked, because its targets are records, not documents.

Both appear in describe_schema with their options and target, alongside each field's label and limits, so an agent and Studio see the same rules the validator applies.

Nest fields

Combine field.array and field.object for repeated groups, such as FAQ entries:

faqs: field.array({
  of: field.object({
    fields: {
      question: field.string(),
      answer: field.text(),
    },
  }),
  optional: true,
}),

Export your collections

Your config must export collections, and functions if you define any. The CLI loads the file directly, so there is no build step, and your app imports the same file for its typed reads.

If you added ready-made features with graft add, combine them with your own collections using mergePrimitives:

graft.config.ts
import { mergePrimitives } from "@usegraft/core";
import * as primitives from "./graft";

export const { collections, functions } = mergePrimitives([
  { collections: { pages }, functions: {} },
  primitives,
]);

Two collections or functions with the same name stop the config from loading with CONFIG_INVALID. Nothing is overridden silently. Owned primitives covers graft add.

See it live: this page's own schema

This table is generated from the schema of the docs collection that renders the page you are reading. The same description answers an agent's describe_schema call and fills Studio's schema view, so it always matches what the compiler enforces.

FieldTypeRequiredDescription
titlestringyesDoc page title (h1, sidebar label, <title>).
descriptionstringyesOne-line summary shown in listings and meta.
sectionstringyesSidebar group: "Get started", "Frameworks", "Content", "Runtime data", "Agents & MCP", "Deploy & operate", or "Reference".
ordernumbernoSort order within the section.

Next steps