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.
Before you touch a single schema file, you need the mental model — because once it clicks, the whole sanity/ Studio reads like one idea repeated. The Naalya content model is built on a single dominant pattern, and almost every type in the registry is a variation on it:
A page is a thin singleton that points at its sections. Each section is its own document. The reusable shapes inside them are shared objects.
That's it. A page document barely holds any content of its own — it carries SEO fields and a handful of reference fields, one per visible block on the screen. Each of those blocks (heroSection, statsSection) is a separate, independently-editable document. The small repeated shapes inside sections (a button, a stat, a card) are registered object types reused by name, never copied inline. And all the rich text in the entire site flows through one portable-text type.
This page explains why the model is shaped this way and the five concepts that hold it together. When you're ready to actually add a type, the step-by-step lives in Creating a Schema.
Where the rules really live
Two in-repo documents are the source of truth for conventions: docs/sanity_rules.md (project-specific) and agents/sanity.agent.md (generic best practices). The singleton mechanism is spelled out in sanity/SINGLETON_PATTERN.md. This page teaches the model those files encode — and flags the few places the actual code drifts from the stated rules.
Why pages are split into sections
The instinct is to make the homepage one big document with every field nested inside it. The project deliberately doesn't. Instead, a page is a page of references, and here's the payoff:
- Editors get a focused screen per block. Each section reference sits in its own
group, so the Studio shows a tab for Hero, a tab for Stats, a tab for the gallery — not one endless scroll. - Sections can be queried on their own. The frontend can fetch just the hero, or just the stats, by its own fixed id — no need to load the entire page.
- Sections stay reusable. A section document is a real, addressable thing; nothing about it is trapped inside a parent.
So when someone says "edit the homepage" in the Studio, they're really editing several linked documents. That's the trade-off you're accepting — a bit more wiring up front — in exchange for clean, composable, independently-fetchable content.
Concept 1: documents vs objects
The single most important distinction in the whole model is type: "document" versus type: "object". Get this right and everything else follows.
A document is a top-level, independently-queryable thing with its own _id and _type — pages, every section, blog records like post and author. An object is an embedded shape that only ever lives inside a document — a button, a stat, a card. It has no standalone existence.
type: "document" | type: "object" | |
|---|---|---|
| Stored as | A top-level, queryable document | An embedded value inside a field/array |
Has its own _id / _type | Yes | No — lives inside a parent |
| Used for | Pages, sections, records (post, siteSettings) | Reusable shapes: buttonConfig, statItem, activityCard… |
| Pulled in via | type: "reference" (and -> in GROQ) | type: "<objectName>" as a field, or inside an array's of |
The wiring difference shows up at the point of use. A document is reached through a reference; an object is dropped in by its type name:
// A document is pulled in by reference:
defineField({ name: "hero", type: "reference", to: [{ type: "heroSection" }] }),
// A shared object is used by its registered name — never re-declared inline:
defineField({ name: "primaryButton", type: "buttonConfig" }),
defineField({
name: "stats",
type: "array",
of: [defineArrayMember({ type: "statItem" })],
}),Rule drift: anonymous inline objects still exist
"No anonymous reusable schema types" is aspirational, not fully enforced. A few sections still embed inline objects — namugongoFacilitiesSection.facilities, the landing gallerySection.images, and siteSettings.campusContacts — instead of reusing the matching shared object (e.g. facilityItem). For new code, reuse the registered shared object.
Concept 2: the page-as-singleton pattern
A singleton is a document the site expects exactly one of — there's only one homepage, one set of site settings, one hero on it. The page-as-singleton pattern combines that idea with the section split: a page document holds only SEO fields plus one single, required reference per section, and the whole thing is locked to a single instance.
export const landingPage = defineType({
name: "landingPage",
type: "document",
icon: HomeIcon,
groups: [
{ name: "seo", title: "SEO & Settings", icon: CogIcon },
{ name: "hero", title: "Hero Section", icon: RocketIcon },
// ...one group per section...
],
fields: [
// The SEO trio, all in the "seo" group:
defineField({ name: "title", type: "string", group: "seo", /* + initialValue */ }),
defineField({ name: "seoTitle", type: "string", group: "seo", /* required, max 60 */ }),
defineField({ name: "seoDescription", type: "text", group: "seo", /* required, max 160 */ }),
// One single, required reference per visible section:
defineField({ name: "hero", type: "reference", to: [{ type: "heroSection" }], group: "hero" }),
defineField({ name: "stats", type: "reference", to: [{ type: "statsSection" }], group: "stats" }),
// ...about, academic, gallery, cta...
],
preview: { /* selects title + seoTitle */ },
});Notice three things every page document shares: the SEO trio (title with an initialValue, seoTitle required + max 60, seoDescription text required + max 160), the one group per section, and the fact that each section field is a single reference — never an array. The full file lives under landingPageSchema/; you rarely need to read every field.
Singletons are enforced by config, not the schema
Here's the part that surprises everyone: nothing in the schema marks a type as a singleton. There's no flag, no __experimental_actions, no marker. "Only one of these" is enforced entirely in sanity.config.ts by two mechanisms that must be kept in sync:
- A fixed
documentIdpins the editing UI to one document, so clicking the type always opens the same instance —S.document().schemaType("landingPage").documentId("landingPage"). - An exclusion filter removes that type from the generic "create new document" list, so an editor simply can't make a second one.
// 1. Pin the type to one document:
S.document().schemaType("landingPage").documentId("landingPage")
// 2. Remove every page/section type from the generic "create new" list:
...S.documentTypeListItems().filter(
(listItem) =>
!["landingPage", "heroSection", "statsSection" /* …~75 strings… */]
.includes(listItem.getId() || "")
);Because the id is fixed and equal to the type name, GROQ fetches each singleton by that id — *[_type == "heroSection" && _id == "heroSection"][0]. That query side is its own topic in Single Documents & Singletons.
The exclusion filter is the easiest thing to forget
That filter(...) array is a hand-maintained ~75-entry list of type-name strings with no automated check. Add a page/section type and forget to list it there, and it appears twice (once in its Pages tree, once in the generic list) and becomes duplicable — silently breaking the by-fixed-id GROQ fetch the frontend depends on. When you add a type, update BOTH the fixed-documentId wiring AND this filter.
Two variations you'll meet
The fixed-id form covers most pages. Two cousins exist — recognize them, don't memorize them:
- A singleton family.
campusContactSectionis one schema backing three fixed documents (-namugongo,-lugazi,-bweyogerere). AdisplayOrderfield orders them on the frontend. Don't treat it as a strict 1:1 singleton. - One document with per-section groups. The leadership page references just two documents; one of them (
leadershipSectionsDocument) is a single document with fourgroups, each holding an array ofleaderMemberobjects. Reach for this when sub-sections are homogeneous and better edited together than split apart.
Concept 3: shared objects
The repeated shapes inside sections — a CTA button, a single stat, an activity card — are first-class registered object types under sanity/schemaTypes/shared/, re-exported from a barrel and referenced by name. Define the shape once; reuse it everywhere.
buttonConfig is the representative example. It's a plain object with a radio-list variant, a boolean toggle, and an icon field that stays hidden until you flip the toggle on:
export const buttonConfig = defineType({
name: "buttonConfig",
type: "object",
icon: LinkIcon,
fields: [
defineField({ name: "text", type: "string", /* required, max 50 */ }),
defineField({ name: "link", type: "string", /* required */ }),
defineField({
name: "variant",
type: "string",
options: { list: [/* default, secondary, outline… */], layout: "radio" },
initialValue: "default",
}),
defineField({ name: "showIcon", type: "boolean", initialValue: false }),
// Hidden until showIcon is toggled on:
defineField({ name: "icon", type: "iconSelect", hidden: ({ parent }) => !parent?.showIcon }),
],
preview: { /* text + variant */ },
});These are the shared objects currently registered — a small, stable vocabulary the whole site draws on:
| Object type | Purpose |
|---|---|
buttonConfig | A CTA button (text/link/variant/optional icon) |
statItem | A single statistic (value, label, icon) |
activityCard | A co-curricular activity card with image |
campusCard | A campus summary card |
facilityItem | A facility/amenity item |
missionVisionCard | A mission/vision card |
faqItem | A question/answer pair |
leaderMember | A leadership profile (image + bio) |
iconSelect | A Tabler icon-name picker (custom React input) |
Reusable objects must be registered too
An object type isn't magic just because it's small. Every type under shared/ — plus inline-array objects like leaderMember, sectionButton, and faqItem — must still be listed in the registry (and the exclusion filter, so they never render as standalone documents). Forget to register one and any type: "<name>" reference to it fails schema validation and typegen.
shared/ objects vs components/ documents
Two folders sound similar but mean opposite things. shared/ holds reusable objects — embedded shapes used by type name. components/ holds standalone reusable section documents (campusListSection, admissionSection, faqSection) that each fetch their own data independently of any page and get their own fixed documentId. A single file can export both: faqSection.ts exports faqItem (the object) and faqSection (the document that contains it).
Concept 4: one rich-text type
Rich text is everywhere — blog bodies, descriptions, captions — and the project resists the temptation to invent a new portable-text shape each time. There is exactly one rich-text type, blockContent, reused everywhere via type: "blockContent".
It's an array whose members are a block (with a fixed set of styles, lists, decorators, and a link annotation) plus an inline image:
export default defineType({
name: "blockContent",
type: "array",
of: [
defineArrayMember({
type: "block",
styles: [/* normal, h1–h4, blockquote */],
lists: [{ title: "Bullet", value: "bullet" }],
marks: {
decorators: [/* strong, em */],
annotations: [/* a "link" object with an href url */],
},
}),
defineArrayMember({ type: "image", options: { hotspot: true } /* + alt, caption */ }),
],
});When to deviate from blockContent
Reuse blockContent for any general rich text. Only define a narrower inline block array when you want a deliberately constrained editor — for example author.ts gives bio a minimal inline block array (only the normal style, no lists) instead of the full blockContent.
Concept 5: the flat registry
Every type the Studio knows about is collected in one place — sanity/schemaTypes/index.ts. It is a single flat array named schemaTypes: each type is imported once and listed once, organized only by comments, with no nesting. sanity.config.ts consumes it exactly once via schema: { types: schemaTypes }.
import author from "./author"; // default export
import blockContent from "./blockContent"; // default export
import { heroSection, landingPage, statsSection } from "./landingPageSchema"; // named
import { buttonConfig, statItem } from "./shared"; // named
import { siteSettings } from "./siteSettings";
export const schemaTypes = [
// Pages + their section documents:
landingPage, heroSection, statsSection, /* … */
// Shared object types:
buttonConfig, statItem, /* … */
// Blog/content records:
post, author, category,
// The one portable-text type:
blockContent,
];There's one wrinkle worth knowing: export styles are mixed. Page-folder and shared types are import { … } named exports from their barrels, while the four blog/content types (post, author, category, blockContent) use export default. Match the existing file's style when you edit, and the registry imports stay consistent.
The registry is the gate — everything must pass through it
A type that isn't in schemaTypes doesn't exist as far as the Studio and typegen are concerned. This is the one list that must include every document and every object. The folder structure organizes the files for humans; the flat registry is what actually wires them in.
Where to go next
You now have the model: pages point at sections, sections hold shared objects, rich text is one type, and one flat registry ties it all together. From here:
Creating a Schema
The step-by-step: write the file, register it, wire the singleton, verify.
Single Documents & Singletons
Fetch a page or section by its fixed _id on the frontend.
Querying with GROQ
The query-module convention and dereferencing sections with ->.
Images & Portable Text
Render that one blockContent type — and Sanity images — in React.