Creating a Schema
A step-by-step walkthrough for adding a new Sanity schema — define it, validate it, preview it, register it, and wire the singleton.
You understand the content model — pages are documents, sections are documents, reusable shapes are objects. Now you actually need to add one. This page is the muscle-memory version: a single end-to-end run that takes a new "Events Section" from an empty file to a managed singleton the frontend can query.
The work splits into two halves, and conflating them is the number-one source of bugs. Defining the schema (steps 1-4) teaches Sanity the shape of your data. Registering and wiring it (steps 5-7) tells the Studio this type exists, and — for anything that should have exactly one instance — that editors may not create a second copy. Skip the second half and your type is invisible, or worse, silently duplicable.
Run every command from the sanity package
The Studio is its own package at sanity/. All the typegen and build commands below (pnpm typecheck, pnpm typegen, pnpm build) are run from inside that folder, not from the web app root. Mixing them up is a common first-day stumble.
We'll build a reusable section document — the most common thing you'll add. (Adding a whole new page is the same moves repeated per section; the content model page covers that variant.)
Step 1: Define the type with the three helpers
Every schema is built from exactly three typed helpers, and you never hand-write raw object literals for a type, a field, or an array member:
defineType— the whole schema (adocumentor anobject).defineField— each field insidefields.defineArrayMember— each entry inside an array'sof: [...].
They exist so TypeScript and pnpm typegen can validate the shape and generate types. A reusable cross-page section lives in sanity/schemaTypes/components/, exports a named const whose name matches the type's name, and starts life looking like this:
import { CalendarIcon } from "@sanity/icons";
import { defineArrayMember, defineField, defineType } from "sanity";
export const eventsSection = defineType({
name: "eventsSection",
title: "Events Section",
type: "document",
icon: CalendarIcon,
fields: [
/* fields go here — Step 2 */
],
});A few decisions are already baked in. type: "document" because a section is independently queryable and gets its own _id. The icon (always from @sanity/icons) is what editors see in the Studio sidebar. And title is only set because "Events Section" differs from the auto-titled name — when they'd match, omit it.
document or object — pick before you type a field
type: "document" is for things stored on their own: pages, sections, records like post. type: "object" is for reusable shapes embedded inside a field or array (a button, a stat, a card). An object has no _id and is referenced by its type name, never copied inline. Get this wrong and the registration and query steps later won't line up.
Step 2: Add fields and their validation
Now fill in fields. Each defineField gets a name, a type, usually a description, and — this is the convention that matters — a validation returned as an array, ordered most-important-first: hard required().error(...) rules first, then soft .max()/.min().warning(...) guidance.
Here our section gets a heading plus an array of event cards. Notice the array member reuses a registered shared object by name rather than redeclaring its fields:
fields: [
defineField({
name: "heading",
type: "string",
description: "Main heading for the events section",
validation: (Rule) => [
Rule.required().error("Heading is required"),
Rule.max(100).warning("Keep heading concise"),
],
}),
defineField({
name: "events",
type: "array",
of: [defineArrayMember({ type: "eventCard" })], // a registered shared object
validation: (Rule) => [
Rule.required().error("Add at least one event"),
Rule.max(8).warning("Too many events can overwhelm the page"),
],
}),
],The error vs warning distinction is real: error blocks publishing, warning is advice the editor can ignore. Reach for the patterns already in the codebase rather than inventing your own.
| Need | Pattern |
|---|---|
| Required field | Rule.required().error("<why>") |
| Soft length limit | Rule.max(60).warning("<guidance>") |
Rule.email().error("Must be a valid email address") | |
| Conditional / cross-field | Rule.custom((value, context) => ...) |
If a field should only matter sometimes, hide it with a hidden predicate and gate its validation with Rule.custom, reading sibling values off context.document. announcement.ts is the canonical example — a buttonText that's required only when a sibling enum equals "action".
Step 3: Reach for shared objects, don't inline shapes
That eventCard in the array isn't hypothetical — it's the rule in action. Any shape you'd repeat (a button, a stat, a card) is a registered object type under sanity/schemaTypes/shared/, referenced by its type name. You use it two ways:
// as a single field value:
defineField({ name: "primaryButton", type: "buttonConfig" }),
// as members of an array:
defineField({
name: "events",
type: "array",
of: [defineArrayMember({ type: "eventCard" })],
}),If eventCard doesn't exist yet, you define it exactly like Step 1 — but with type: "object", no icon required, and you'll register it the same way in Step 5. A single file may even export both: faqSection.ts exports the faqItem object and the faqSection document that contains it.
An unregistered object breaks typegen, not just the UI
A type: "eventCard" reference to an object that you never registered in schemaTypes fails schema validation and typegen — not with a friendly Studio message, but a build error. Reusable objects are not optional extras; they must be registered (Step 5) just like documents.
Step 4: Give it an icon and a preview
Every document and reusable object defines a preview so the Studio shows something meaningful instead of "Untitled". Two flavors. Use a plain select when the title and media come straight off fields; use prepare() when you need to compute a subtitle — count an array with <field>.length, or reach through a reference with dotted paths.
Our section computes its subtitle from the heading and the event count:
preview: {
select: { heading: "heading", count: "events.length" },
prepare({ heading, count }) {
return {
title: "Events Section",
subtitle: `${heading} — ${count || 0} events`,
};
},
},That closes out the schema file. It's now a complete, valid definition — but the Studio still has no idea it exists. That's the next half.
Step 5: Register it in the schema registry
sanity/schemaTypes/index.ts is the single, flat registry — every type imported once and listed once in the schemaTypes array. sanity.config.ts consumes it exactly once via schema: { types: schemaTypes }. Component and page types come through their folder barrel, so re-export there first:
export { eventsSection } from "./eventsSection";
// export { eventCard } from "./eventsSection"; // if you also defined the object hereThen import from the barrel and append to the array, under the matching comment block:
import { eventsSection } from "./components";
export const schemaTypes = [
// ...existing types...
eventsSection,
];Watch the export style — it is not uniform
Most types use named exports (import { eventsSection } from "./components"). But the four blog/content types — post, author, category, blockContent — use export default and are imported without braces (import post from "./post"). Match whatever the neighbouring file already does.
At this point the type is real: it'll show up in the Studio. But for a section it shows up in the generic "create new document" list — which means an editor could spin up five of them. If it's meant to be a singleton, you're not done.
Step 6: Wire the singleton in sanity.config.ts
Here's the part people forget, because nothing in the schema marks a type as "there is only one". Singleton-ness lives entirely in sanity.config.ts, through two moves that must be kept in sync.
First, pin a fixed documentId so clicking the type in the Studio always opens the same document — by convention the id equals the type name:
S.listItem()
.title("Events Section")
.icon(CalendarIcon)
.child(
S.document().schemaType("eventsSection").documentId("eventsSection")
);Second — and this is the easy one to miss — add the type name to the exclusion filter that strips singleton types out of the generic create list:
...S.documentTypeListItems().filter(
(listItem) =>
![
"landingPage",
"heroSection",
// ...~75 hand-maintained type-name strings...
"eventsSection", // <- add yours
].includes(listItem.getId() || "")
);Both moves, or neither — the filter has no safety net
The exclusion array is a hand-maintained ~75-entry literal with no automated check. Add the fixed documentId but forget the filter, and your type appears twice (once pinned, once generic) and becomes duplicable — silently breaking the by-fixed-id GROQ fetch the frontend relies on. Whenever you wire a singleton, update BOTH.
Skipping Step 6 entirely is a legitimate choice: leave it out and the type just lives in the generic document list as ordinary, multi-instance content. You only need the singleton wiring when there should be exactly one.
Step 7: Create the document, then verify
A brand-new singleton starts life as nothing — a fixed-id query against it returns null, not an error, until the document exists. So open the Studio once and click into "Events Section" to actually create the document. Then prove the schema compiles, from the sanity package:
pnpm typecheck
pnpm typegen
pnpm buildpnpm typegen is the one that matters most — it regenerates the GROQ/TypeScript types your frontend imports, so the new fields become known to the web app.
A missing singleton reads as null forever
If you wire the singleton but never open it in the Studio to create the document, the frontend query returns null indefinitely and the section silently doesn't render. "Create it once in the Studio" is a required step, not a nicety.
The whole loop, at a glance
Every new schema follows the same rhythm — define, register, wire, verify:
Define & validate
defineType / defineField / defineArrayMember, with validation as a most-important-first array.
Reuse, don't inline
Repeated shapes are registered object types referenced by type name.
Register once
Re-export from the barrel, import and append to schemaTypes in index.ts.
Wire the singleton
Fixed documentId AND the exclusion filter in sanity.config.ts — both or neither.
The last step is always the frontend: a query module under app/queries/* so the site can actually read your new content.
Where to go next
The Content Model
The reference for documents vs objects, shared types, blockContent, and naming.
Single Documents & Singletons
How the frontend fetches your new singleton by its fixed _id.
Querying with GROQ
Build the query module that reads the fields you just added.
Adding a Page
The end-to-end version: schema, structure, route, and query for a whole page.
The Content Model
The big-picture shape of the Sanity Studio — pages as singletons, sections as references, shared objects, one rich-text type, and a flat registry.
Querying with GROQ
The read path for Sanity content — the shared client, the query-module convention, GROQ projections, and prefetch-on-server hydrate-on-client.