Structuring Sanity Schemas to Avoid Query Waterfalls

By Nayan Kyada · · 6 min read

Part of The Sanity + Next.js Guide

Most engineers new to Sanity treat it like a relational database. They create a category document, then reference it from post. They create an author document, reference it everywhere. Then they write a GROQ query that projects references, and wonder why their list queries get slower as the content grows.

The pattern that keeps list queries lean is strategic denormalisation. Instead of always reaching for references, I embed commonly-queried fields directly in the parent document. I still use references for the Studio UI and for canonical data, but I mirror critical fields so GROQ can return everything in one round-trip.

The problem: reference projection is a hidden N+1

When you write *[_type == "post"] { title, author-> }, Sanity resolves the join server-side — it's still a single HTTP request from Next.js, but the query engine has to look up each referenced author. On a page that lists fifty posts with unique authors, that's fifty extra lookups inside the query, and with several references per card (author, category, image asset) the cost adds up on uncached requests.

Next.js App Router server components don't block the browser, but they do block time-to-first-byte. A slow data fetch delays streaming. If your LCP element depends on that data, every millisecond of query time lands on LCP.

The pattern: mirror critical fields at write time

Here's a schema I use for blog posts. The author field is a reference, but I also store authorName and authorSlug as plain strings.

// schemas/post.ts
import { defineField, defineType } from 'sanity'
 
export default defineType({
  name: 'post',
  type: 'document',
  fields: [
    defineField({ name: 'title', type: 'string' }),
    defineField({ name: 'slug', type: 'slug', options: { source: 'title' } }),
    defineField({
      name: 'author',
      type: 'reference',
      to: [{ type: 'author' }],
    }),
    defineField({
      name: 'authorName',
      type: 'string',
      description: 'Cached from author reference. Updated via document action.',
      readOnly: true,
    }),
    defineField({
      name: 'authorSlug',
      type: 'string',
      readOnly: true,
    }),
    defineField({ name: 'publishedAt', type: 'datetime' }),
  ],
})

I mark the cached fields readOnly: true so editors don't manually edit them. Then I write a custom document action that runs whenever an editor publishes a post. The action fetches the referenced author, copies name and slug.current into authorName and authorSlug, and patches the post document.

// structure/actions/syncAuthorFields.ts
import { useDocumentOperation } from 'sanity'
import { useEffect } from 'react'
 
export function useSyncAuthorFields(props: any) {
  const { patch, publish } = useDocumentOperation(props.id, props.type)
  const { draft } = props
 
  useEffect(() => {
    if (!draft?.author?._ref) return
    const authorRef = draft.author._ref
 
    fetch(`/api/sanity/author/${authorRef}`)
      .then((res) => res.json())
      .then((author) => {
        patch.execute([
          { set: { authorName: author.name } },
          { set: { authorSlug: author.slug.current } },
        ])
      })
  }, [draft?.author?._ref])
 
  return { label: 'Publish', onHandle: () => publish.execute() }
}

This is a simplified example. In production I use a webhook or a Sanity listener plugin to sync fields in the background, so the Studio doesn't block on the patch. The key insight is: write-time cost is cheap, read-time cost is expensive. I'd rather spend 50 ms on publish than 150 ms on every page render.

The GROQ query is now trivial

Instead of projecting the reference, I select the cached fields.

*[_type == "post"] | order(publishedAt desc) [0..11] {
  _id,
  title,
  "slug": slug.current,
  authorName,
  authorSlug,
  publishedAt
}

No author->. No joins. Sanity returns twelve flat documents in one query with no reference resolution to do.

When to denormalise and when to project

I denormalise when:

  • The field appears in list queries (post grids, search results, navigation menus).
  • The source document changes infrequently (author name, category slug).
  • The cost of syncing is low (a webhook or a Studio action that runs once per publish).

I still use projection when:

  • I need the full author object on a single post page. One projection on a detail route is fine.
  • The source changes often and must stay in sync (like a live event countdown or a stock ticker).
  • The field is only queried in Studio context, not frontend.

What to measure

Don't take the win on faith — measure the query before and after. Time the list query from your server component (or check the query timing in Vision), then mirror the hot fields (authorName, categorySlug, categoryColor) and compare. If the difference is negligible on your dataset, keep the references and skip the extra sync logic.

The trade-off is complexity: you now maintain sync logic, and it has to be reliable. For content sites where query speed directly affects Core Web Vitals, this pattern is non-negotiable.

Keeping mirrored fields honest

The denormalisation pattern has one genuine liability: mirrored values drift. An editor renames a category, and every post that mirrored the old title now disagrees with the source document. Handled lazily, this erodes trust in the content itself — so the sync has to be mechanical, not procedural.

The lightweight fix is a webhook-driven repair. A Sanity webhook filtered to the source type calls a route handler that patches every referencing document:

// app/api/sync-mirrors/route.ts (webhook target)
// `client` is a write-enabled Sanity client; `verifyAndParse` is your
// signature check (e.g. isValidSignature from @sanity/webhook).
export async function POST(req: Request) {
  const { _id, title } = await verifyAndParse(req);
 
  const stale: string[] = await client.fetch(
    `*[_type == "post" && category._ref == $id && categoryTitle != $title]._id`,
    { id: _id, title }
  );
 
  const tx = stale.reduce(
    (tx, id) => tx.patch(id, (p) => p.set({ categoryTitle: title })),
    client.transaction()
  );
  await tx.commit();
  return Response.json({ updated: stale.length });
}

A rename fans out to a handful of patches within seconds, and the transaction keeps the repair atomic. For higher-stakes setups the same logic lives in a custom document action so the editor sees "this will update 14 posts" before publishing — same mechanics, better consent.

Deciding what's safe to mirror

Not every field tolerates this treatment. The pattern works when the mirrored value is small, stable, and display-only — a title, a slug, a colour token. It turns hostile when the value is large (portable text — you're duplicating documents), hot (view counts — every change fans out), or semantically load-bearing (prices, inventory — stale data has consequences beyond cosmetics).

My rule of thumb: mirror what appears in cards and listings, dereference what appears on detail pages. Listings are where the per-document reference lookups actually add up — twenty cards, each pulling three references — while a detail page resolving one post's references in a single GROQ projection was never the bottleneck. Scope the denormalisation to where the measured problem lives and the drift surface stays small enough to trust.

Frequently asked questions

What is a GROQ reference projection N+1 and how do I avoid it?

Writing `author->` in a projection makes Sanity resolve that reference for every matching document — on a 50-post list with unique authors, that's 50 extra lookups on the query engine side (still one HTTP request, but more work per query). The fix for list views is mirroring small, stable fields (a name, a slug) directly onto the parent document at write time, so the read-time query needs zero dereferencing. See the denormalisation pattern below.

When should I denormalise a Sanity reference instead of projecting it?

Denormalise when the field shows up in list queries (cards, grids, search results) and the source document changes infrequently — an author name, a category slug. Keep projecting when you need the full object on a single detail page (one projection there was never the bottleneck), or when the source data changes too often to trust a cached copy, like a live event countdown.