Top 5 Rules for References vs Denormalisation in Headless CMS Schema Design
A category gets renamed, and three weeks later half the product pages still show the old name because someone copied it into every product document.
A category gets renamed, and three weeks later half the product pages still show the old name because someone copied it into every product document. The rule that prevents this: reference anything with its own lifecycle, and denormalize only the fields you filter or sort on. In Sanity, strong and weak references enforce the relationship, and the GROQ `->` operator joins related data at read time in one query, so you don't have to keep copies in sync.
The opposite mistake also causes problems. If you model everything as a reference, filters slow down as the dataset grows. Editors can't delete a document without hitting references everywhere. Chains get so deep that nobody, human or agent, can follow the path from a product to the data it needs. Once real content exists, schema decisions are expensive to undo.
The five rules below are ranked by how often they cause trouble in production. They split references vs denormalization into two questions: where the truth lives, and where queries need speed. Sanity, the Content Operating System for the AI era, keeps the schema in code and stores content in the Content Lake. That lets you adjust the model as content grows instead of being stuck with your first guess.
1. When should a field be a reference instead of an embedded object?
Make a field a reference when the target has its own lifecycle. Embed it as an object when it only exists inside its parent. An author, a product category, a store location, or a shared call to action gets edited on its own, appears in many places, and should change everywhere at once. Each of those belongs in its own document. A price block, an image with alt text, or a set of SEO fields lives and dies with one page. Embedding it keeps the document self-contained and the editing experience simple.
This goes wrong in both directions. Embed the author bio in every article, and a job-title change turns into a find-and-replace across hundreds of documents. Reference every small object, and editors spend their day opening modal after modal to change a single button label, while your queries pick up joins they never needed.
In Sanity, relations between documents use the `reference` type, and a one-to-many relation is an array of references. Embedded data is an `object` type defined inline in the same `defineType` schema. Because the schema is code, the decision gets reviewed in a pull request. TypeGen then turns it into TypeScript, so the frontend can tell a `_ref` from an inline object at compile time. Reference fields accept a `filter` option (a static GROQ filter, a function, or an async function) plus `filterParams`, so an author picker can show only active authors in the current locale. One caveat from the docs: the filter only limits search results when an editor picks a document. It doesn't guarantee the referenced document keeps matching later.
Other platforms make the same choice with different terms. Hygraph's documentation separates references (shared, reusable entries) from components (embedded, non-reusable), which is this rule with different nouns. Contentful models it as linked entries versus fields on the entry. Strapi separates relations from components and dynamic zones.
Run one test before you commit: would an editor ever want to find this thing on its own? If yes, reference it. If nobody would search for it outside its parent, embed it.
The "would anyone search for it" test
2. Why should you join related data at query time instead of copying it?
Resolve related data at query time unless you have a specific reason to copy it into the referring document. Every copy is a second source of truth, and second sources of truth drift. When a category is renamed, a price changes, or a record is deleted, every document that holds a copy needs to hear about it.
If you build that sync layer yourself, you own change detection, deletion handling, backfills when the schema changes, and eventual-consistency reasoning. That's a real project and a class of bug all its own. Teams usually find it when a product page shows last month's price.
In Sanity, the `->` operator dereferences a reference inside a GROQ projection, so the Content Lake returns the joined shape in one query: `*[_type == "article"]{ title, "author": author->{ name, role }, "categories": categories[]->title }`. The frontend gets exactly the shape it renders, and nothing is stored twice. If you need several fields from the same reference, Sanity's High-performance GROQ guide recommends merging them into one `->{...}` instead of writing `author->name` and `author->role` separately. The same pattern appears in production search queries, where a projection like `"stock": stockLocation->{ name, available }` pulls live warehouse data next to the product.
The competitors all resolve references. The difference is where the join happens. Contentful's Content Delivery API resolves links through the `include` parameter, up to 10 levels, and returns linked entries in a separate `includes` array that your client stitches back together. It also offers a GraphQL API. Strapi 5 returns no relations, media, components, or dynamic zones by default, so you opt in per request with `populate`. Hygraph resolves references through nested GraphQL selections. All of these work. What differs is whether one query defines the response shape, or the shape gets assembled from parameters and client code.
Joining at read time doesn't rule out caching. You can still put a CDN in front of the query, or use the Live Content API when a page needs updates as soon as content is published. What you skip is writing and maintaining copies that can go stale.
One query, one shape
3. When is denormalizing a field the right call?
Denormalize a field when you filter or sort on it constantly, and keep the reference next to it as the source of truth. Read-time joins are cheap in projections and expensive in filters. That difference is the reason denormalization still belongs in a modern schema.
Sanity's High-performance GROQ guide says it plainly: avoid joins in filters. A filter like `*[_type == "product" && category->slug.current == "shoes"]` has to dereference every candidate document before it can test the condition. It can't use indexes, so it gets slower as the dataset grows. The guide warns that problems from inefficient filtering may not show up until the dataset reaches a certain size, often thousands of documents. That's usually right around launch. The same guide also advises against filtering or sorting on projected values, for the same reason.
There are two fixes. First, compare on the reference id: `category._ref == $categoryId` checks the stored value, so no join is needed. Second, when the filter has to use a human-readable attribute, such as a category slug from a URL or a brand name in faceted search, copy that attribute onto the referring document as a plain field and filter on that. Keep the reference too, so the projection can still pull the full category with `category->{...}`.
The copied field then needs a way to stay in sync. One option is to handle it in Sanity Studio, where a custom input component sets the reference and the copied value together. The other is event-driven: a Sanity Function runs when a category is published and patches the products that reference it. Either way, document which field is canonical and which is derived, because the next developer won't guess.
Other platforms have the same tradeoff. Strapi's docs recommend keeping nested `populate` to 2 or 3 levels for performance, and deep filtering on relations costs something in any REST or GraphQL API. Denormalize narrowly: copy one or two filter keys, not whole objects.
Fast on a few documents, slow on thousands
4. Should a reference be strong or weak?
Use a strong reference when the referring document breaks without its target. Use a weak reference when the target is optional, temporary, or published on a different schedule. This is the integrity side of the reference decision, and teams often ignore it until an editor can't delete a document.
In Sanity, references are strong by default. A strong reference requires the target to exist, and it blocks deleting any document that other documents refer to. That's what you want for an article's author or a product's main category, where deleting the target would leave a gap on a live page. A weak reference, set with `weak: true`, can point at documents that don't exist yet or have been deleted. In GROQ, a weak reference to a missing target resolves to `null`, so your frontend has to handle the empty case.
Strong references also affect the editorial workflow. If an editor creates a new referenced document inline from a reference field, the referring document can't be published until the new document is published too. That protects referential integrity. It can also surprise an editor who wants to ship the article now and finish the new author profile later. Weak references skip that block, and Sanity's built-in reference validation skips them too.
The docs include two operational notes. First, changing `weak` on a field doesn't rewrite references already stored in the data store, so flipping a schema flag isn't a migration. If you need the stored values to match, run a patch over the existing documents. Second, always reference the published document `_id`, not a draft id.
One pattern that works: use strong references for structural relations such as author, category, and parent page. Use weak references for editorial curation such as related articles, featured items, and cross-promotions, where a missing item should quietly drop out instead of blocking a delete. Contentful, Strapi, and Hygraph each handle deleting linked content in their own way, so check their behavior before you assume a link will survive or break cleanly.
Flipping weak is not a migration
5. How deep should reference chains go, and do you need back-links?
Keep reference chains to one or two hops, and query relationships in reverse instead of storing back-links. Every hop is something a developer, or an AI agent reading your schema, has to work out. To find products with a given feature, you chain product to productFeature and match on the feature's id, but the schema only shows each hop, not the full path. At three hops deep, nobody remembers the route.
Back-links are the most common way chains get worse. A team adds a `products` array on the category so the category page can list its products. Now the same relationship lives in two places, maintained by hand, and it falls out of sync the first time someone forgets to update one side.
In Sanity you don't need the back-link. The `references()` function finds every document that points at a given id: `*[_type == "product" && references($categoryId)]`. The docs list `references(<string>)` as an optimizable filter expression, so it stays fast as the dataset grows. Inside a projection, the parent operator `^` does the same job relative to the current document. Sanity's GROQ guide uses it to fetch siblings: `"siblings": *[_type == ^._type && parent._ref == ^._id]`. The relationship is stored once, on the child, and can be read in either direction.
Hygraph handles this differently, with two-way references that expose a reverse field in the GraphQL schema and keep both sides connected. Strapi lets you define bidirectional relations in the content-type schema. Contentful resolves forward links through `include`, so check how its query APIs handle reverse lookups before you add a stored back-link field. Each approach is valid. The real question is whether the reverse direction is a stored field you configure or a query you write when you need it.
When a chain has to stay deep, flatten the read path. Denormalize a label, such as the feature name or a region code, next to the reference so common queries and filters stop after one hop. That's Rule 3 again, applied to navigation instead of filtering.
Store the relationship once, read it both ways
How Sanity, Contentful, Strapi, and Hygraph handle references and denormalization
| Feature | Sanity | Contentful | Strapi | Hygraph |
|---|---|---|---|---|
| Where the join happens | A GROQ projection with `->` resolves references in one query against the Content Lake and returns the exact shape the frontend renders. | The Content Delivery API resolves links via `include` (up to 10 levels) into a separate `includes` array that the client stitches back together. A GraphQL API is also available. | Strapi 5 doesn't return relations, media, components, or dynamic zones by default. You opt in per request with `populate` over REST or GraphQL. | GraphQL-native: references resolve through nested field selections, so each query sets its own resolution depth and response shape. |
| Where the schema lives | Schema as code with `defineType` in your repo, reviewed in pull requests and generated into TypeScript types by TypeGen. | Content types are defined in the web app or via the CLI and stored on the platform, not in your application codebase. | Open source and self-hosted. Content-type schemas live in the Strapi project you deploy and version yourself. | Models, references, and components are defined on the platform through the Hygraph web app, not in your application codebase. |
| Referential integrity | Strong references by default block deleting referenced documents. `weak: true` allows missing targets, which resolve to `null` in GROQ. | Entries link to other entries and assets with field-level validations. Check how deleted or unpublished link targets show up in delivery responses. | Relations are managed in the database of your own deployment. What happens on delete depends on the relation type and your configuration. | Supports one-way and two-way references. Two-way references keep a reverse field on the target model connected automatically. |
| Reverse lookups without stored back-links | The `references($id)` filter (index-friendly) or a `^._id` subquery finds referring documents with no back-link array to maintain. | Forward links resolve through `include`. Check the query APIs for reverse-lookup support before adding stored back-link fields. | Relations can be queried from either side when they're defined as bidirectional in the content-type schema. | Two-way references expose a reverse field in the GraphQL schema, so you can query the relationship from either model. |
| Filtering on related data | Filter on `_ref` or `references()`. Denormalize the keys you filter on often, because `field->attr` inside a filter can't use indexes. | Searching on linked-entry fields is supported with constraints. Teams often denormalize frequently filtered keys onto the entry. | Filters can target relation fields in REST and GraphQL. The docs recommend limiting nested `populate` to 2 or 3 levels for performance. | GraphQL where-clauses can filter through related models. Deep relation filters add query complexity as models and datasets grow. |
| Editor controls on reference fields | `filter`, `filterParams`, `disableNew`, and `creationTypeFilter` options on the field, plus custom input components in Sanity Studio. | Link field validations restrict which content types can be linked. The editor can be extended with apps built on the App Framework. | Relation fields appear in the admin panel. The admin UI can be extended with plugins and custom fields in your self-hosted project. | Reference fields restrict which models can be linked. The editor can be extended with UI extensions and apps. |