Skip to content

Modelling references in Sanity without an N+1 on every page

A reference is a pointer, not an embed. Dereference inside the projection, model the direction that reads well, and never resolve in a loop.

Sanity blog4 min readships at docs/solutions/blog-sanity/modelling-references-without-n-plus-one.md

Tags: sanity · groq · references · data-modelling · performance

You model a post with an author and some categories, exactly as the docs suggest:

defineField({ name: "author", type: "reference", to: [{ type: "author" }] }),
defineField({ name: "categories", type: "array", of: [{ type: "reference", to: [{ type: "category" }] }] }),

Then the index page renders "undefined" where the author's name should be, so somebody fixes it:

const posts = await client.fetch(POSTS_QUERY);

for (const post of posts) {
  post.author = await client.fetch(`*[_id == $id][0]{ name }`, { id: post.author._ref });
  post.categories = await Promise.all(
    post.category.map((c) => client.fetch(`*[_id == $id][0]{ title }`, { id: c._ref })),
  );
}

Twenty posts with three categories each is eighty-one requests to render one page. Promise.all makes it concurrent, not fewer, and Sanity's API rate limits are per-project, so the fix that "made it fast" is the thing that occasionally 429s in production.

A reference is a pointer

Stored, a reference is { _type: "reference", _ref: "<document id>" }. Nothing else. The referenced document is not embedded, and querying the post gives you the pointer, which is why post.author.name is undefined.

GROQ resolves pointers with ->, inside the projection, as part of the same query:

*[_type == "post"] | order(publishedAt desc)[0...$limit] {
  _id,
  title,
  "slug": slug.current,
  "author": author->{ name, "image": image.asset->url },
  "categories": categories[]->title
}
  • author->{ ... } follows one reference and projects fields from the target.
  • categories[]->title maps over an array of references and pulls one field from each, giving you string[] instead of an array of objects.
  • The renaming ("author":) keeps the client-side shape flat and obvious.

That is one request. The join happens inside Sanity's content lake, where it is an index lookup rather than a round trip.

Choose the direction that reads well

References point one way. Which document holds the pointer determines which query is cheap.

Post holds the author. Rendering a post is a dereference. Rendering an author's post list is a reverse lookup:

*[_type == "post" && author._ref == $authorId] | order(publishedAt desc)

Author holds an array of posts. Rendering the author page is a dereference, and rendering the post needs a reverse lookup, plus every new post now requires editing the author document, which is a worse editing experience and a source of conflicts.

The rule that holds up: the many side holds the pointer to the one side. A post has one author, so the post points at the author. Reverse lookups on _ref are indexed and fast; there is no need to denormalise for them.

For genuinely many-to-many relationships (posts and categories) put the array on the side that editors think of as the parent. Editors tag a post with categories, not a category with posts, so the array lives on the post.

Reverse lookups belong in the query too

An author page needs the author and their posts. That is one query, not two:

*[_type == "author" && slug.current == $slug][0] {
  name,
  bio,
  "image": image.asset->url,
  "posts": *[_type == "post" && author._ref == ^._id] | order(publishedAt desc)[0...20] {
    title,
    "slug": slug.current,
    publishedAt
  }
}

^ walks up to the enclosing scope, so ^._id is the author's id. Nested subqueries like this are the GROQ feature that removes most remaining round trips.

Weak references and what happens on delete

By default Sanity refuses to delete a document that something else references, which is usually what you want, and occasionally infuriating. If a reference should survive its target disappearing, mark it weak:

defineField({
  name: "relatedGuide",
  type: "reference",
  to: [{ type: "guide" }],
  weak: true,
});

A weak reference can dangle. The projection then returns null, and your renderer must handle that: "guide": relatedGuide->{ title } gives null, not a missing key. Non-weak references are the right default precisely because they turn "someone deleted the author" into a deliberate decision rather than a runtime surprise on a page.

Confirming you fixed it

  1. Grep for the anti-pattern. Any fetch inside a map or a for over query results is the bug:

    grep -rn "client.fetch" src/ | grep -v "sanity/lib"
    
  2. Count requests. In the Vision tool, run the single projected query and check it returns everything the page renders. If the page still needs a second query for something visible, the projection is incomplete.

  3. Watch the API dashboard in sanity.io/manage after deploying. Request count per page view should be one or two: the page's data and, at most, a separate query for something genuinely independent like a global settings document.

  4. Make sure the fix stayed. Add the query to sanity/lib/queries.ts rather than to the page, so the next person extends the projection instead of adding a loop.

The underlying idea transfers: any content API with references (Contentful, Strapi, Payload, a SQL database) has a way to resolve them server-side in one round trip. Doing it in application code, one row at a time, is the same bug wherever you find it.