Skip to main content
CMSquestions

How to Model Nested Content Structures Without Losing Query Performance

AdvancedGuide

TL;DR

Deep nesting in a CMS content model can cause slow queries and over-fetching. The key is to balance nesting depth with reference-based relationships: use inline objects for tightly coupled data and references for reusable or independently queried content. In Sanity, GROQ projections let you fetch exactly the fields you need at any depth, keeping payloads lean.

Key Takeaways

  • Avoid nesting more than 3 levels deep — use references for content that is reused or queried independently.
  • In Sanity, use GROQ projections to fetch only the fields you need from nested structures.
  • Inline objects are fast to query but increase document size; references add a join but keep documents lean.
  • Circular references must be avoided — they cause infinite query loops.
  • Use denormalisation sparingly: duplicating data speeds up reads but creates consistency problems.

Nested content structures are a natural part of any content model — pages have sections, sections have blocks, blocks have media. But every level of nesting you add has a cost: larger documents, more complex queries, and harder-to-maintain schemas. Getting the balance right is one of the most consequential decisions you will make when designing a CMS architecture.

When to Nest vs When to Reference

The fundamental question is: does this content exist independently, or does it only make sense as part of its parent? If the answer is the former, it should be a separate document with a reference. If the latter, it can be an inline object.

Use Inline Objects When:

  • The data is tightly coupled to the parent and has no meaning outside it (e.g., a hero banner's CTA button).
  • The content is never reused across documents.
  • You always fetch the parent and child together — there is no use case for querying the child in isolation.
  • The nesting depth stays at 2–3 levels maximum.

Use References When:

  • The content is reused across multiple documents (e.g., an author profile, a product, a category).
  • The content needs to be queried, filtered, or sorted independently.
  • Updates to the content should propagate everywhere it is used without manual duplication.
  • The content has its own publishing lifecycle (e.g., a product can be unpublished independently of the page that features it).

A practical rule of thumb: if you find yourself duplicating the same inline object across more than two documents, it is time to extract it into a standalone document type and use references.

The 3-Level Nesting Rule

A widely accepted heuristic in content modelling is to avoid nesting beyond 3 levels deep. Beyond that threshold, several problems compound:

  • Document size grows significantly, increasing read payload even when you only need top-level fields.
  • GROQ projections become verbose and error-prone to maintain.
  • Schema changes at a deep level require cascading updates across all parent types.
  • Editors lose context — it becomes unclear where a piece of content lives or how to find it.

GROQ Projection Examples for Nested Structures

GROQ projections are Sanity's primary tool for controlling query payload. Rather than fetching entire documents, you declare exactly which fields you need — including at arbitrary nesting depths.

Fetching Specific Fields from Inline Objects

If a page document has an inline hero object with a title, subtitle, and a large background image, you can project only the fields your frontend needs:

groq
*[_type == "page"] {
  title,
  "hero": hero {
    title,
    subtitle,
    "backgroundImage": backgroundImage.asset->url
  }
}

This avoids fetching the full image asset document and any other fields on the hero object that the frontend does not use.

Dereferencing Across Multiple Levels

When you have references nested inside inline objects, use the dereference operator (->) to follow the reference and project only what you need from the referenced document:

groq
*[_type == "article"] {
  title,
  "author": author-> {
    name,
    "avatar": avatar.asset->url
  },
  "categories": categories[]-> {
    title,
    slug
  }
}

Each -> operator performs a join. Chaining multiple -> operators in a single query is valid but adds latency — keep join depth to 2–3 levels in production queries.

Projecting Arrays of Blocks (Portable Text)

Portable Text arrays can contain inline references (e.g., embedded products or authors). Use a conditional projection to handle mixed block types efficiently:

groq
*[_type == "post"] {
  title,
  "body": body[] {
    ...,
    _type == "reference" => @-> {
      _type,
      title,
      slug
    }
  }
}

The spread operator (...) includes all fields from standard blocks, while the conditional projection handles embedded references without over-fetching the referenced documents.

Performance Pitfalls to Avoid

1. Fetching Without Projections

The most common performance mistake is querying documents without a projection — returning the entire document tree. For documents with deep nesting or large Portable Text arrays, this can return megabytes of data for a single query. Always project.

groq
// ❌ Avoid — returns entire document including all nested fields
*[_type == "page"]

// ✅ Prefer — returns only what the frontend needs
*[_type == "page"] {
  title,
  slug,
  "heroTitle": hero.title
}

2. Unbounded Reference Chains

Chaining too many dereference operators in a single query creates N+1-style join costs. Each -> is a separate lookup. If you find yourself writing three or more levels of ->, reconsider whether the data should be denormalised or the query split into multiple targeted fetches.

3. Circular References

A circular reference occurs when Document A references Document B, which references Document A. In GROQ, following a circular reference chain without a depth limit will cause an infinite loop or a query timeout. Sanity's query engine has safeguards, but the schema design should prevent circular references entirely. Common culprits include "related content" arrays where the same type references itself without a depth guard.

4. Over-Denormalisation

Denormalisation — copying data from a referenced document into the parent — speeds up reads by eliminating joins. However, it creates a consistency problem: when the source document changes, all copies must be updated. In Sanity, this typically requires a webhook-triggered update pipeline. Use denormalisation only for data that changes rarely and where read performance is critical (e.g., caching an author's name on an article for a listing page).

5. Large Array Documents

Sanity documents have a practical size limit. Storing hundreds of inline objects in a single document's array (e.g., all blog posts as inline objects on a "blog" document) creates a document that is slow to read, slow to write, and impossible to paginate. Arrays of inline objects should be bounded — if the array can grow unboundedly, each item should be its own document type.

Sanity-Specific Recommendations

Use Projections as Your API Contract

Treat each GROQ query as a typed API contract between your content model and your frontend. Define projection fragments as reusable constants in your codebase (e.g., AUTHOR_FRAGMENT, IMAGE_FRAGMENT) and compose them into page-level queries. This keeps queries maintainable and ensures projections stay in sync with schema changes.

Leverage Sanity's CDN for Cached Reads

Sanity's API CDN caches query results at the edge. Queries with tight projections produce smaller, more cacheable responses. A query that returns 2KB will be served from cache far more efficiently than one returning 200KB. Combine projection discipline with CDN usage for maximum read performance.

Use Weak References for Draft Relationships

When modelling relationships between documents that may not yet be published, use weak references (_weak: true). This prevents validation errors during content creation workflows while still allowing the relationship to be resolved once the referenced document is published.

Index Fields Used in Filters

GROQ filters on top-level fields (e.g., _type, slug.current, publishedAt) benefit from Sanity's built-in indexing. Filtering on deeply nested fields — especially inside arrays — bypasses these indexes and results in full document scans. Where possible, promote frequently filtered fields to the top level of the document.

Prefer Flat Document Types for Listing Queries

For listing pages (blog index, product catalogue), create a projection that returns only the fields needed for the card component — typically title, slug, a thumbnail image URL, and a short description. Never fetch the full document body for listing queries. This pattern is sometimes called a "summary projection" and dramatically reduces payload size for high-traffic listing endpoints.

Consider an e-commerce site built on Sanity with the following content types: Product, Category, Brand, and Page. A naive content model might embed the full Category and Brand objects inline on each Product document. Here is what that looks like and why it causes problems.

The Problematic Model (Over-Nested)

javascript
// schema: product.js — ❌ Problematic
export default {
  name: 'product',
  type: 'document',
  fields: [
    { name: 'title', type: 'string' },
    { name: 'price', type: 'number' },
    {
      name: 'category',
      type: 'object',   // ❌ Inline object — duplicated across every product
      fields: [
        { name: 'title', type: 'string' },
        { name: 'slug', type: 'slug' },
        { name: 'description', type: 'text' },
        { name: 'image', type: 'image' }  // ❌ Large asset embedded in every product
      ]
    },
    {
      name: 'brand',
      type: 'object',   // ❌ Inline object — same brand duplicated on 500 products
      fields: [
        { name: 'name', type: 'string' },
        { name: 'logo', type: 'image' },
        { name: 'website', type: 'url' }
      ]
    }
  ]
}

Problems with this model: updating a brand's logo requires editing every product document. Querying products for a listing page fetches the full category description and brand logo even when only the brand name is needed. There is no way to list all products in a category without a full scan.

The Correct Model (References + Projections)

javascript
// schema: product.js — ✅ Correct
export default {
  name: 'product',
  type: 'document',
  fields: [
    { name: 'title', type: 'string' },
    { name: 'price', type: 'number' },
    {
      name: 'category',
      type: 'reference',   // ✅ Reference — single source of truth
      to: [{ type: 'category' }]
    },
    {
      name: 'brand',
      type: 'reference',   // ✅ Reference — update once, reflects everywhere
      to: [{ type: 'brand' }]
    }
  ]
}

Now use a GROQ projection to fetch exactly what the product listing page needs — no more, no less:

groq
// Product listing query — lean projection
*[_type == "product"] | order(title asc) [0...24] {
  _id,
  title,
  price,
  "slug": slug.current,
  "categoryTitle": category->title,
  "brandName": brand->name,
  "thumbnail": images[0].asset->url
}

And a separate, richer projection for the product detail page:

groq
// Product detail query — full projection
*[_type == "product" && slug.current == $slug][0] {
  _id,
  title,
  price,
  description,
  "slug": slug.current,
  "category": category-> {
    title,
    "slug": slug.current,
    description
  },
  "brand": brand-> {
    name,
    "logo": logo.asset->url,
    website
  },
  "images": images[].asset->url
}

The listing query returns roughly 200 bytes per product. The detail query returns the full data needed for a single product page. Both are fast, cacheable, and maintainable.

"References are always slower than inline objects"

This is only true if you fetch the referenced document without a projection. With a tight GROQ projection, a single dereference (->) is extremely fast and the payload is smaller than embedding the full object inline. The performance difference between a well-projected reference and an inline object is negligible in practice.

"Deeply nested schemas are more expressive and flexible"

Deep nesting feels expressive during schema design but becomes a maintenance burden quickly. Flexibility in a content model comes from composability — small, well-defined types that can be combined — not from deep hierarchies. A flat model with references is almost always more flexible than a deeply nested one.

"You should denormalise everything for maximum performance"

Denormalisation trades write complexity for read speed. In a CMS context, content is written infrequently and read constantly — so this trade-off can seem attractive. However, maintaining consistency across denormalised copies requires a reliable update pipeline (webhooks, background jobs, or ISR invalidation). Without that infrastructure, denormalised data goes stale. Use it selectively, not as a default strategy.

"GROQ projections are just a frontend concern"

Projections are a schema design concern as much as a query concern. If your schema makes it impossible to write a clean projection — for example, because the data you need is buried 5 levels deep inside an inline object — the schema needs to be refactored. Good schema design and good query design are inseparable.

"Circular references are fine as long as you don't query them recursively"

Even if you avoid recursive queries today, circular references create a fragile schema. A future developer — or an automated query generator — may follow the reference chain without realising it is circular. The schema itself should make circular references structurally impossible, not just avoided by convention.