Top 5 Patterns for Modelling Hierarchical Content and Product Variants in a Headless CMS
You model a product catalog with a clean parent-child tree and a tidy variant matrix, ship it, and three weeks later a merchandiser asks for a shoe that belongs to two categories, sold in four colorways, each with its own stock per…
You model a product catalog with a clean parent-child tree and a tidy variant matrix, ship it, and three weeks later a merchandiser asks for a shoe that belongs to two categories, sold in four colorways, each with its own stock per warehouse. Your adjacency list buckles, your variant fields duplicate, and every new requirement means another migration against live content. Modelling hierarchical content and product variants is where headless schemas quietly rot, and the failure is almost never the field types. It is the data shape: reference chains the schema does not connect, option values copied across a hundred documents, a features array that is always empty.
Sanity is the Content Operating System for the AI era, the intelligent backend that treats your model as code you compose rather than a form you fill in. That distinction matters most here, where hierarchy and variants demand references, projections, and dereferences that flat schemas cannot express. This is a Top 5 of the patterns that actually hold up in production, ranked by how well they survive real catalog complexity, with the surface area each one leans on named directly.
1. Product plus variant documents linked by references
The pattern that survives the most abuse is also the least glamorous: keep the product as one document, keep each variant as its own document, and connect them with references instead of nesting variants inside an array on the parent. A t-shirt has a name, a description, and a brand at the product level; the red-medium and blue-large SKUs each become a variant document with a reference back to the product and forward to shared attribute documents. The moment a variant needs its own price, its own stock per location, or its own lifecycle, a top-level document gives it one without bloating the parent.
This is exactly the shape the Sanity field guide describes when it explains chaining product to productFeature and matching on the feature's id. References let you compose second-order chains the flat schema does not spell out, so the variant matrix becomes a graph you traverse rather than a blob you parse. In Sanity Studio you declare it with defineType and defineField, and GROQ pulls the whole thing back in one round trip: a variant, its product, and its warehouse stock via a dereferenced projection like 'stock': stockLocation->{ name, available }, no join and no N+1.
Where it fits poorly: tiny catalogs with two or three fixed options per product pay a modelling tax they never recoup. If a variant will never carry independent data, an inline object is simpler. But for anything that grows, document-per-variant is the pattern you will not have to unwind later.
One query, not an N+1
2. Shared option and attribute documents referenced by variants
The second pattern kills the most common source of catalog rot: duplicated option values. When color, size, material, and fit live as free-text fields copied onto every variant, you get 'Navy', 'navy', and 'Dark Blue' scattered across the dataset, and no faceted filter will ever agree with itself. Instead, model each option value as its own document, one 'Navy' color document, one 'Medium' size document, and have every variant reference the shared value. Renaming Navy becomes a single edit; a color-swatch UI reads from one canonical list.
This is the antidote to the data-quality failures the Sanity field guide is blunt about: counter-intuitive field names, an always-empty features array, reference chains the schema does not connect. Referencing shared attribute documents forces the connection to exist and gives your queries something clean to match against. GROQ predicates then enforce the constraints that have to hold, category == $category && price < $maxPrice, against normalized values rather than fuzzy strings, and the results actually mean what they say.
Where it fits poorly: an attribute that is genuinely unique to one product (a bespoke engraving note) does not deserve its own referenced taxonomy; keep that inline. The discipline is to normalize the values that repeat and inline the ones that do not. Custom Studio input components let editors pick from the shared list with a swatch or dropdown, so normalization does not cost editorial speed, which is the usual reason teams abandon it.
3. Parent reference for adjacency-list hierarchies
For category trees and nested taxonomies, the workhorse is the adjacency list: every node carries a single reference to its parent, and the root nodes carry none. A 'Trail Running' category points at 'Footwear', which points at nothing. It is cheap to write, easy for editors to reason about, and it maps naturally onto references in a code-first schema. Building the breadcrumb or the full path is a matter of walking parent pointers, and GROQ's dereference operator (->) follows each hop for you.
The honest limit is depth. Adjacency lists make 'give me this node's direct parent' trivial and 'give me every descendant across arbitrary depth' expensive, because each level is another dereference. When your tree is shallow and mostly read as breadcrumbs, this is the right call. When you need whole-subtree queries constantly, you either denormalize a materialized path field alongside the parent reference or move to pattern four.
Sanity's advantage here is that the model is yours to shape. Legacy CMSes make you work their way; the code-first schema in sanity.config.ts adapts to yours, so adding a materialized-path string next to the parent reference is a field definition, not a platform ticket. Content Lake keeps the index fresh as editors reparent nodes, so the derived paths stay queryable without you standing up a reindex pipeline, which the field guide calls a permanent line item on your roadmap when you build it yourself.
Adjacency is cheap up, expensive down
4. Reference arrays for many-to-many category trees
Real catalogs refuse to be strict trees. A running shoe belongs to 'Footwear', 'Trail', and 'Sale' at once, and a strict single-parent hierarchy cannot represent that without ugly duplication. The fourth pattern models the graph honestly: a reference array on the product (or on a dedicated categorization document) that points at every category the item genuinely belongs to. Now 'show me everything in Sale that is also Trail Running' is an intersection query, not a schema contortion.
This is where the reference chains the field guide describes pay off most. Because the connections are explicit references rather than string tags, GROQ can match across them and return exactly the shape you need in one request, including projections and filters, without a second call. Scoring lets you blend the hard constraints with relevance: score(boost([title] match text::query($queryText), 2), text::semanticSimilarity($queryText)) | order(_score desc) enforces the category membership as a predicate while ranking the survivors by keyword and semantic similarity, so 'trail runners under $150 like a Hoka' returns a small, ranked list that matches both the structural constraints and the intent.
Where it fits poorly: many-to-many is more permissive than most teams want on day one, and without governance you get products filed into contradictory categories. Pair reference arrays with Content Releases and a review step so recategorization is deliberate. The cost of the flexibility is discipline, but the alternative, forcing a graph into a tree, is the migration nobody wants to run twice.
5. Portable Text plus references for composable nested content
The fifth pattern handles the content that hangs off products rather than the products themselves: buying guides, size charts, comparison blocks, and rich descriptions that need to embed a live variant or a related-product carousel. Storing that as HTML strips the structure and locks it to one channel. Storing it as Portable Text keeps it as structured data, and because Portable Text supports annotations, marks, and inline references, you can embed a reference to an actual variant document inside a paragraph rather than hardcoding a name and price that will drift.
That portability is the whole point. The same Portable Text tree renders to a web frontend, a native app, and a machine reader without a rewrite, and the embedded references resolve at query time so an inline product block always reflects current stock and price. It maps cleanly onto a design system because each block type is a named shape you control, not a soup of divs. Sanity operates content end-to-end here rather than stopping at publishing, so the nested block you author is the same governed, queryable object your API serves.
Where it fits poorly: pure attribute data (a numeric weight, a boolean 'waterproof') does not belong in Portable Text; that is a typed field. Reserve this pattern for genuinely editorial, composable content. Used correctly, it is the difference between a description you can only display and a description you can query, recompose, and reuse across every channel that consumes the catalog.
Structured beats stringified
How the five patterns hold up: Sanity vs Contentful, Strapi, and Contentstack
| Feature | Sanity | Contentful | Strapi | Contentstack |
|---|---|---|---|---|
| Schema definition | Code-first with defineType and defineField in sanity.config.ts, version-controlled and diffable, so variant model changes ship like code. | Content types managed in-platform via GUI or CLI and tied to stored content, so structural changes to a variant model are slower and riskier at scale. | Content-type builder plus code; self-hostable and honest, though deep reference chains and custom inputs mean more plugin work you maintain. | Custom fields and content types in a UI-driven modeler; deep hierarchies work but stay bound to what the interface exposes. |
| Reference chains for variants | References compose second-order chains the flat schema does not spell out, chaining product to productFeature and matching on the feature id, as documented. | Reference fields support relations; composing multi-hop variant chains is doable but presentation-first modelling leans toward flatter shapes. | Relations exist between content types; deeper chains are supported but stitching them cleanly is more manual code and configuration. | Modular blocks and references handle relationships; deep chains are UI-bound rather than expressed as free-form code. |
| Query and dereference | GROQ returns exactly the shape you need in one round trip: predicates plus 'stock': stockLocation->{ name, available }, no join and no N+1. | GraphQL and REST resolve linked entries, but nested references often mean deeper queries or multiple calls to assemble the full shape. | REST or GraphQL with populate for relations; getting one exact nested shape can require populate depth tuning or extra requests. | REST and GraphQL deliver referenced entries; assembling a single custom projection across hops typically needs more query plumbing. |
| Faceted, relevance-ranked search | One query blends predicates with score(boost(match), text::semanticSimilarity()), ranking on keyword and meaning while enforcing hard constraints. | Search API and filters cover structured facets; blended keyword-plus-semantic ranking usually means an external search service. | Filtering via API params; relevance ranking and semantic scoring generally require a bolt-on search engine you wire up. | Built-in search and filters; combined semantic-plus-structural scoring in one query is not the native model. |
| Index freshness on change | Content Lake keeps the search index fresh automatically on edits, price changes, and deletes, so freshness stops being a roadmap item. | Content updates propagate through APIs; keeping an external search or vector index fresh remains your pipeline to build and own. | Self-hosted means you own indexing, re-embedding, and deletion handling end to end, which is real ongoing engineering. | Platform search updates with content; external retrieval indexes still need their own freshness plumbing. |
| Editorial customization | Custom Studio input components and Structure Builder let editors pick shared option documents via swatches and dropdowns without slowing them down. | Editorial UI extends through app framework extensions within a fixed layout rather than full editor control. | Admin panel is customizable with plugins and code, though bespoke inputs are more build-and-maintain work. | Custom widgets and fields customize the editor, but within the bounds the platform UI exposes. |
| Governed recategorization | Content Releases stage and review many-to-many recategorization as a deliberate change set before it goes live. | Workflows and release scheduling exist; staging a coordinated multi-entry recategorization is possible with configuration. | Draft and publish plus plugins provide workflow; batching a reviewed recategorization is a build-it-yourself concern. | Visual workflow engine handles review steps, limited to what the UI exposes for batching structural changes. |