Schema
Describe your content once, and get types everywhere from it.
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 compilechecks 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>/:
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:
---
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
| Builder | Holds | Use it for |
|---|---|---|
field.string() | A short string | Titles, names, slugs |
field.text() | A long string | Summaries and longer text |
field.number() | A number | Prices, quantities, ratings |
field.boolean() | true or false | Flags such as featured |
field.datetime() | An ISO date and time | Publish dates |
field.asset() | { key, alt? } | Images and files. See Assets |
field.select({ options }) | One value from a fixed list | A status, a section, a size |
field.reference({ to }) | The slug of a document in another collection | A product's category, a post's author |
field.object({ fields }) | A nested group of fields | An author with a name and a URL |
field.array({ of }) | A list of another field | Tags, FAQ entries |
field.json() | Any JSON value | A 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
| Option | Applies to | Effect |
|---|---|---|
maxLength | string, text | The longest allowed value |
pattern | string, text | A regular expression the value must match |
min, max | number | The allowed range |
int: true | number | Whole numbers only |
maxItems | array | The most entries allowed |
format: "money" | number | An amount in the currency's minor unit (cents for USD, whole yen for JPY). Implies int. Studio shows and edits it as money |
currency | number with format: "money" | The ISO currency code Studio shows. Defaults to USD |
label | every field | What 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:
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.
| Field | Type | Required | Description |
|---|---|---|---|
title | string | yes | Doc page title (h1, sidebar label, <title>). |
description | string | yes | One-line summary shown in listings and meta. |
section | string | yes | Sidebar group: "Get started", "Frameworks", "Content", "Runtime data", "Agents & MCP", "Deploy & operate", or "Reference". |
order | number | no | Sort order within the section. |
Next steps
- Reading content covers the three reads.
- Assets covers image and file fields.
- Operational data covers collections your users write to.