How to Model Pages and Modules in a Headless CMS (Schema Patterns)
Six months into a build, someone asks for a page that reuses the hero from the homepage, the pricing table from the product page, and a testimonial carousel that marketing wants to edit without a deploy.
Six months into a build, someone asks for a page that reuses the hero from the homepage, the pricing table from the product page, and a testimonial carousel that marketing wants to edit without a deploy. The team discovers that every page type was modeled as a bespoke document with its fields hardcoded in a fixed order. There is no way to reorder blocks, no way to reuse a module across templates, and the only path to "one more layout" is another migration. This is the most common schema failure in headless CMS work: modeling the page you can see today instead of the composition system the business will need tomorrow.
Sanity is the headless content platform where the schema is code you own, defined with `defineType` in `sanity.config.ts` and shipped as part of Sanity Studio. That distinction matters here, because page and module modeling is not a one-time decision. It is a system you refactor as the business grows, and the Content Operating System approach treats your content model as a first-class, versioned, composable artifact rather than a rigid template locked to a vendor's field UI.
This guide covers the durable patterns: separating pages from modules, using arrays of blocks for composition, referencing shared content, and keeping the whole thing typed and previewable.
Separate the page from the modules it renders
The first mistake is treating a page as a flat bag of fields: a hero headline, a hero image, a features title, three feature items, a CTA label, a CTA url, and so on down the document. It renders fine for exactly one layout. The moment a second page wants the same hero above a different body, you are copying fields and keeping two schemas in sync by hand.
The durable pattern is to model a page as a thin shell with metadata (title, slug, SEO fields, publish state) plus a single ordered list of modules. Each module is its own object type: a hero, a feature grid, a testimonial block, a rich-text section, a CTA band. The page does not know what a hero looks like internally; it only knows it holds an ordered array of blocks that each render themselves. This is the difference between modeling a document and modeling a composition system.
In Sanity you express this with `defineType`. The page document holds a `content` field of type array, and the array's `of` list enumerates the module object types it accepts. Because the schema is code in your repo, adding a new module is a pull request, not a support ticket, and the page shell stays stable while the library of blocks grows underneath it. That separation is what lets marketing assemble new layouts from existing parts without a developer touching the page type at all.
Model composition as an array of typed blocks
Once the page is a shell, the array of modules becomes the heart of the model, and how you type that array decides how far it scales. A weak version uses one giant object with an optional field for every possible block and a `type` string to switch on. It technically works, but validation is loose, half the fields are always null, and the editing experience is a wall of empty inputs.
The strong version makes each block a distinct object type with only its own fields, then lists all of them in the array's `of`. A `heroModule` has a headline, an eyebrow, an image, and a layout enum. A `featureGridModule` has an array of feature items. A `ctaModule` has copy plus a reference to a link. Each block validates independently, and editors get an insert menu of real, named blocks rather than a single ambiguous form.
Sanity's array-of-objects modeling is the native expression of this. In Sanity Studio, an array of block types renders as an add-item menu, each item gets its own editing form and preview, and Structure Builder lets you shape how those documents are organized in the desk. Because Portable Text is itself a typed array of blocks, the same mental model that governs rich text governs page layout: ordered, typed, portable content that a frontend, a design system, or a downstream channel can map deterministically. You are not storing a rendered page. You are storing structured intent.
Reuse shared content with references, not copies
Some content is not a layout block, it is an entity that appears in many places: an author, a product, a pricing plan, a reusable call-to-action, a navigation menu. Inlining that content into each page is how you end up with the same author bio spelled three different ways and a price that is correct on two pages and stale on a third.
The pattern is to model these as their own documents and connect them with references. A blog post references an author document. A landing page references the canonical pricing plan. A CTA module references a shared promotion document that marketing updates in one place. When the underlying entity changes, every page that references it reflects the change, because there is one source of truth instead of many copies drifting apart.
Sanity references are first-class and, critically, queryable in both directions. GROQ's dereference operator (`->`) lets you follow a reference and project exactly the fields you need in the same query, so a page can pull its module list, follow each module's references, and return one fully-shaped response in a single round trip. You can also query the inverse: find every page that references a given plan before you change it. That bidirectional awareness is what makes references safe to rely on at scale. Legacy CMSes tend to bolt relationships on as loose foreign keys; here the reference graph is part of the queryable Content Lake, so integrity and impact analysis are built into how you read content, not an afterthought.
Decide when to nest and when to flatten
Modeling teams burn weeks on one question: should this be an embedded object inside the parent, or a standalone document you reference? Get it wrong in the flatten direction and you cannot reuse or independently permission the content. Get it wrong in the nest direction and you create thousands of orphan documents nobody can find, plus a reference graph that is expensive to traverse.
The rule of thumb that holds up: embed content that only ever exists in the context of its parent and is never reused (the hero on a specific landing page, the ordered blocks of one article). Promote to a referenced document anything that has its own lifecycle, its own permissions, its own editors, or that appears in more than one place (authors, products, shared CTAs, taxonomy terms). Reuse and independent governance are the signals to reference; strictly-owned, single-use content is the signal to embed.
This is exactly where a model-your-business posture beats a work-our-way one. Because Sanity schemas are plain code you refactor with the rest of your app, promoting an embedded object to a referenced document is a migration you write and run, not a limitation you live with. TypeGen regenerates TypeScript types from the schema so the frontend fails to compile if a shape changed, which turns modeling refactors into safe, reviewable pull requests instead of silent runtime breakage. The model adapts to how your business is actually structured rather than forcing your content into whatever hierarchy the vendor's UI assumed.
Keep editors in the layout with visual editing
A composition model that only makes sense in an abstract field tree is a model editors will fight. If someone assembling a page from ten modules cannot see what the page looks like as they reorder blocks, they will ask for a page builder, and you will end up back at rigid templates to give them one.
The fix is to connect the structured model to a live rendered preview, so editing an array of blocks and watching the actual page update are the same action. The blocks stay structured and typed underneath; the editing surface just happens to look like the page. This is how you keep the composability of a headless model without giving up the tangibility of a WYSIWYG builder.
Sanity's Presentation Tool and Visual Editing do exactly this: the Studio renders your live frontend alongside the editor, click-to-edit maps a region of the page back to the field that produced it, and the Live Content API streams changes so reordering modules updates the preview in real time. Because the model is still an array of typed blocks in the Content Lake, nothing about this preview layer compromises the structure. You get click-into-the-page editing and clean, portable content, which is the combination rigid page builders and pure field-tree editors each give up one half of.
Structure and preview are not a tradeoff
Type the model end to end and query it in one shape
The last failure mode is a model that is correct in the CMS but untyped everywhere else. The frontend guesses at the shape of each module, a renamed field breaks a component at runtime in production, and every module added to the array is a fresh chance for a null-reference bug that no build step caught.
The discipline that prevents this is a single source of truth for shape, flowing from schema, to types, to query, to component. The schema defines each block. Generated types make the frontend aware of every block variant. The query asks for precisely the fields each component consumes, and a switch on the block type routes each item to its renderer. Add a module, regenerate types, and the compiler tells you which components need to handle it.
In Sanity this is a tight loop. TypeGen reads your `defineType` schemas and emits TypeScript, so a `content` array typed as a union of your module types is known to the frontend. GROQ then lets you fetch the whole page in one round trip: project the page's metadata, expand the module array, follow each module's references with `->`, and return exactly the shape your React tree consumes, no over-fetching and no second call. Operators like `match()` for text and `score()` for relevance ride the same query language when a module needs search or ranking. The result is a page-and-module system where the schema, the types, the query, and the rendered output all agree, which is the whole point of modeling structured content instead of storing pages.
Page and module modeling across headless platforms
| Feature | Sanity | Contentful | Strapi | Storyblok |
|---|---|---|---|---|
| Composition primitive | Page shell plus an array of typed block objects, each an independent object type listed in the array's `of`, with its own form and preview. | References and rich-text embedded entries compose layouts; block types are content models linked into a list field. | Dynamic zones let a page hold an ordered list of components, each a defined component schema. | Blocks (nestable components) inside Stories are the native composition unit, built for visual assembly. |
| Schema authoring | Schema is code you own: `defineType` in `sanity.config.ts`, shipped with the Studio and versioned in your repo. | Content types authored in the web app or via the CMA/migration CLI; the field UI is vendor-defined. | Content-Type Builder plus code; models live as JSON/schema files in your self-hosted project. | Block and component schemas configured in the Storyblok app, with a management API for automation. |
| Fetching a full page | One GROQ query projects page metadata, expands the module array, and follows references with `->` in a single round trip. | GraphQL or REST; deep links often need include-depth tuning or multiple queries to fully resolve nested entries. | REST or GraphQL with populate parameters to expand dynamic-zone components and relations per request. | REST/GraphQL delivery API returning the story tree; resolve_relations expands linked stories. |
| Reusable shared entities | Model as documents, connect with references, and query the inverse to find every page using an entity before you change it. | Linked entries provide reuse; back-reference discovery relies on the API or the web app's usage view. | Relations between collection types provide reuse across a self-hosted schema. | Reference fields link stories; a single story can be embedded across many pages. |
| In-context visual editing | Presentation Tool renders your live frontend in the Studio; click-to-edit maps regions to fields; Live Content API streams changes. | Live Preview app and a Visual Editing SDK provide in-context editing, configured per environment. | Preview is configured per project; live visual editing typically requires custom wiring or plugins. | Visual Editor is core to the product, with click-to-edit on blocks in the rendered preview. |
| Type safety to the frontend | TypeGen turns schemas into TypeScript, so the module-union type is known and a shape change fails the build. | GraphQL codegen can generate types from the schema; REST responses are typed by hand or via community tools. | Types via GraphQL codegen or community typing packages; not a first-party schema-to-TS pipeline. | Community typegen and GraphQL codegen options exist; typing the block tree is largely a manual mapping. |