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[]->titlemaps over an array of references and pulls one field from each, giving youstring[]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
Grep for the anti-pattern. Any
fetchinside amapor aforover query results is the bug:grep -rn "client.fetch" src/ | grep -v "sanity/lib"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.
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.
Make sure the fix stayed. Add the query to
sanity/lib/queries.tsrather 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.