# 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.

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

```ts
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:

```ts
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:

```groq
*[_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:

```groq
*[_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:

```groq
*[_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:

```ts
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:

   ```bash
   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.

---

Agentic Boilerplate: A Next.js repo your agent already knows. Free during launch, then $99 once.

- Site map for agents: https://agenticboilerplate.com/llms.txt
- Public API: https://agenticboilerplate.com/openapi.json
- Contact: agenticstudio@gmail.com
