How to Handle Circular References in a CMS Content Model
TL;DR
Circular references occur when Document A references Document B, which references Document A — creating an infinite loop when querying. In Sanity, you avoid this by limiting query depth with GROQ projections, using one-directional references, or denormalising data where necessary.
Key Takeaways
- Circular references cause infinite loops in recursive queries and must be avoided or bounded.
- In GROQ, use projections to limit reference depth.
- Design your content model with directional references: A references B, but B does not reference A.
- If bidirectional relationships are needed, use incoming reference queries rather than stored back-references.
- Sanity does not prevent circular references at the schema level — it is the developer responsibility to manage query depth.
A circular reference in a CMS content model occurs when two or more documents reference each other in a cycle. For example, a Category document references an Article document as its "featured" piece, while that same Article references the Category it belongs to. When a query attempts to resolve these references recursively, it enters an infinite loop — or, in practice, crashes or times out.
Why Circular References Are Dangerous
Most query languages and data-fetching layers are not inherently protected against circular reference traversal. In GROQ (Sanity's query language), if you write a query that dereferences a reference field and then dereferences a field inside that result, you can inadvertently walk a cycle indefinitely. The consequences include:
- Stack overflows or query timeouts in your data-fetching layer.
- Unexpectedly large response payloads that degrade frontend performance.
- Difficult-to-debug data structures that are hard to serialise to JSON.
- Broken static site generation pipelines that attempt to resolve all references at build time.
How Sanity Handles (and Does Not Handle) Circular References
Sanity stores references as lightweight pointer objects — a { _type: 'reference', _ref: 'documentId' } value. The document store itself does not prevent you from creating a cycle. Sanity will happily save Document A referencing Document B and Document B referencing Document A. The problem only surfaces at query time, when you attempt to resolve those references into full document trees.
This means the responsibility for managing circular references falls entirely on the developer — both at the content model design stage and at the query authoring stage.
Strategy 1: Design Directional References
The most robust solution is to design your content model so that references only flow in one direction. Think of your document types as nodes in a directed acyclic graph (DAG). Establish a clear hierarchy:
- An Article references a Category (the article knows its category).
- A Category does NOT store a list of its articles (the category does not know its articles).
- To list articles in a category, query for articles that reference that category — do not store the relationship in both directions.
This pattern is sometimes called the "single source of truth" for a relationship. One document owns the reference; the other side derives it through a query.
Strategy 2: Use GROQ Projections to Bound Query Depth
Even when circular references exist in your data, you can prevent infinite traversal by writing explicit GROQ projections that stop at a known depth. Instead of using the spread operator ... or following references without bounds, explicitly project only the fields you need at each level.
For example, instead of resolving a category's featured article and then that article's category again, stop the projection at the second level by only selecting scalar fields (strings, numbers, slugs) rather than following further references.
Strategy 3: Use Incoming Reference Queries for Bidirectional Relationships
When you genuinely need to navigate a relationship in both directions — for example, showing a category page that lists all its articles — use a GROQ query that looks up documents referencing the current document, rather than storing a back-reference array.
The GROQ pattern *[_type == 'article' && references($categoryId)] fetches all articles that reference a given category ID. This avoids storing the relationship in the category document entirely, eliminating the circular reference at the data model level.
Strategy 4: Denormalise Where Appropriate
In some cases, the cleanest solution is to denormalise: instead of storing a reference to a document, copy the specific fields you need directly into the parent document. For example, rather than referencing an Author document from an Article and then resolving the author's name and avatar at query time, you might store a lightweight authorSnapshot object inline. This trades data freshness for query simplicity and eliminates the risk of circular traversal.
Denormalisation is best suited to data that changes infrequently and where the cost of staleness is low — such as author display names or category labels.
Detecting Circular References in an Existing Content Model
If you suspect circular references exist in a content model you have inherited, you can audit it by:
- Mapping all reference fields in your schema and drawing a directed graph of document type relationships.
- Looking for any cycle in that graph (e.g., A → B → C → A).
- Writing a test GROQ query that attempts to resolve two levels of references and checking whether the response payload grows unexpectedly.
- Reviewing any schema fields typed as arrays of references — these are common sources of accidental back-references.
Consider a Sanity project with two document types: category and article. A naive content model might look like this:
// ❌ Circular reference — avoid this pattern
// category.js
export default {
name: 'category',
type: 'document',
fields: [
{ name: 'title', type: 'string' },
{
name: 'featuredArticle',
type: 'reference',
to: [{ type: 'article' }], // category → article
},
],
}
// article.js
export default {
name: 'article',
type: 'document',
fields: [
{ name: 'title', type: 'string' },
{
name: 'category',
type: 'reference',
to: [{ type: 'category' }], // article → category
},
],
}With this schema, a GROQ query that naively dereferences both sides will loop:
// ❌ This query will attempt infinite traversal
*[_type == 'category'][0] {
title,
featuredArticle-> {
title,
category-> {
title,
featuredArticle-> {
// ...and so on forever
}
}
}
}The Fix: Directional References + Incoming Query
Refactor the schema so that only the article holds the reference. Remove featuredArticle from the category document entirely:
// ✅ Directional reference — article owns the relationship
// category.js
export default {
name: 'category',
type: 'document',
fields: [
{ name: 'title', type: 'string' },
// No reference to article — the category does not know its articles
],
}
// article.js
export default {
name: 'article',
type: 'document',
fields: [
{ name: 'title', type: 'string' },
{
name: 'category',
type: 'reference',
to: [{ type: 'category' }], // article → category (one direction only)
},
],
}To display a category page with its articles, use an incoming reference query instead of a stored back-reference:
// ✅ Fetch a category and all articles that reference it
*[_type == 'category' && slug.current == $slug][0] {
title,
// Derive the article list via an incoming reference query
"articles": *[_type == 'article' && references(^._id)] {
title,
slug,
publishedAt
}
}Bounding Depth When You Cannot Refactor the Schema
If you are working with a legacy schema that already has circular references and cannot be immediately refactored, use explicit projections to stop traversal at a safe depth. Only select scalar fields at the deepest level you need:
// ✅ Bounded depth — stops traversal before the cycle completes
*[_type == 'category'][0] {
title,
featuredArticle-> {
title,
slug,
// Resolve the category reference but only select scalar fields
// — do NOT dereference featuredArticle again
category-> {
_id,
title,
slug
// Stop here. Do not follow featuredArticle from this level.
}
}
}This query resolves two levels of references safely. The key discipline is: at the deepest level you intend to traverse, select only scalar fields and never dereference further reference fields.
Misconception 1: "Sanity Will Warn Me If I Create a Circular Reference"
Sanity does not validate reference cycles at the schema or document level. You can freely create Document A referencing Document B and Document B referencing Document A — the Studio will save both without complaint. The problem only manifests at query time, which means it can slip through development unnoticed until a query is written that traverses the cycle.
Misconception 2: "GROQ Automatically Stops Infinite Reference Traversal"
GROQ does not have a built-in cycle-detection mechanism that halts traversal automatically. The depth of reference resolution is entirely determined by the structure of your query. If your query explicitly dereferences a field at every level, GROQ will attempt to follow it. The developer is responsible for writing projections that terminate at a finite depth.
Misconception 3: "Storing Back-References Is the Right Way to Model Bidirectional Relationships"
A common instinct — especially for developers coming from relational databases — is to store references in both directions: the article stores its category, and the category stores an array of its articles. In a document store like Sanity, this creates a maintenance burden (you must keep both sides in sync) and introduces circular reference risk. The idiomatic approach is to store the reference in one place and derive the reverse relationship through a query using references() or a filter on the reference field.
Misconception 4: "Circular References Only Matter in Recursive Queries"
Even non-recursive queries can be affected. Static site generators and data-fetching utilities that attempt to resolve all references in a document tree — such as @sanity/client with automatic reference expansion, or custom serialisers — may walk the reference graph without an explicit depth limit. If a circular reference exists in the data, these tools can hang or produce malformed output even when the GROQ query itself appears straightforward.
Misconception 5: "Denormalisation Is Always Bad Practice in a CMS"
Developers familiar with relational database normalisation sometimes resist denormalisation in a CMS context. However, document databases like Sanity are designed to support denormalised data patterns. Storing a snapshot of an author's name and avatar directly in an article document is a legitimate and often preferable pattern — it eliminates a reference traversal, avoids circular reference risk, and makes the document self-contained for rendering. The trade-off is that the snapshot may become stale if the author updates their profile, which is acceptable for many use cases.