Naalya Handbook
Sanity CMS

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 (a document or an object).
  • defineField — each field inside fields.
  • defineArrayMember — each entry inside an array's of: [...].

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:

sanity/schemaTypes/components/eventsSection.ts
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:

sanity/schemaTypes/components/eventsSection.ts
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.

NeedPattern
Required fieldRule.required().error("<why>")
Soft length limitRule.max(60).warning("<guidance>")
EmailRule.email().error("Must be a valid email address")
Conditional / cross-fieldRule.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:

anatomy: using a shared object
// 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:

sanity/schemaTypes/components/eventsSection.ts
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:

sanity/schemaTypes/components/index.ts
export { eventsSection } from "./eventsSection";
// export { eventCard } from "./eventsSection"; // if you also defined the object here

Then import from the barrel and append to the array, under the matching comment block:

sanity/schemaTypes/index.ts
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:

sanity/sanity.config.ts (structure)
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:

sanity/sanity.config.ts (exclusion filter)
...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:

terminal (run inside sanity/)
pnpm typecheck
pnpm typegen
pnpm build

pnpm 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

On this page