How I Model Sanity References to Avoid N+1 Queries in Next.js RSC
May 01, 2026 · 5 min read
When you fetch a document that references other documents in Sanity, the naïve approach creates a waterfall: fetch the parent, parse the references, fetch each child. In Next.js server components this adds 200–400 ms of latency per level of nesting. I've shipped half a dozen content sites where a poorly-structured reference field turned a 180 ms page response into 600 ms.
The root problem is treating references like foreign keys in SQL. Sanity references are just {_type: 'reference', _ref: '...'} stubs. If you don't project the referenced document inline using GROQ, you'll fetch it separately — either in a second server component or a follow-up client.fetch() call. Both patterns serialize sequentially and destroy TTFB.
Here's the pattern I use in every Sanity project now: always project references inline at query time, never fetch them in a second pass.
Inline projection with GROQ arrow syntax
Suppose you have a page schema with a author reference field pointing to a person document. The wrong way:
// app/blog/[slug]/page.tsx (bad pattern)
import { client } from '@/sanity/client'
const pageQuery = `*[_type == "page" && slug.current == $slug][0]{
title,
author // just {_type: 'reference', _ref: 'abc123'}
}`
export default async function Page({ params }: { params: { slug: string } }) {
const page = await client.fetch(pageQuery, { slug: params.slug })
// Second fetch — waterfall
const author = await client.fetch(
`*[_type == "person" && _id == $id][0]{name, bio}`,
{ id: page.author._ref }
)
return <article>{page.title} by {author.name}</article>
}This creates two round-trips to Sanity's API. If your Sanity project is in the US and your Next.js edge function is in Mumbai, you've added 150 ms of network latency.
The fix: use the arrow -> operator to project the referenced document inline.
*[_type == "page" && slug.current == $slug][0]{
title,
"author": author->{
name,
bio,
"image": image.asset->url
}
}Now page.author is a plain object with name, bio, and image. One fetch, one round-trip. I measure 200–250 ms TTFB consistently on projects with this pattern, compared to 450+ ms with the two-fetch approach.
Handling arrays of references
If you have an array of references — say, a relatedPosts field — the same principle applies. Project each item inline.
// sanity/queries/page.ts
export const pageQuery = groq`
*[_type == "page" && slug.current == $slug][0]{
_id,
title,
body,
"relatedPosts": relatedPosts[]->{
_id,
title,
"slug": slug.current,
publishedAt,
"coverImage": coverImage.asset->{
url,
"lqip": metadata.lqip,
"dimensions": metadata.dimensions
}
}
}
`The []-> syntax maps over the array and projects each reference. Without it, you'd get an array of {_ref: '...'} stubs and need a second query like *[_id in $ids] to hydrate them — another waterfall.
In one project with a "Related Articles" sidebar, this change dropped the server component render time from 340 ms to 140 ms. The difference shows up in your Vercel function logs as "duration".
Nested references and when to stop
You can nest projections arbitrarily deep, but every level increases query complexity and response size. My rule: project two levels max unless you have a specific reason.
Example: a page references a category, and that category references a parentCategory. Projecting both inline:
*[_type == "page" && slug.current == $slug][0]{
title,
"category": category->{
title,
"parent": parent->{
title,
"slug": slug.current
}
}
}This works, but if parentCategory also had a parent, I'd stop here and handle the breadcrumb trail client-side or in a separate cached query. Beyond two levels, the GROQ query becomes hard to type with TypeGen and the JSON response balloons.
TypeScript and Sanity TypeGen
Sanity's TypeGen (via sanity typegen generate) infers types from your GROQ queries if you use the groq template literal from next-sanity. The inline projection syntax types correctly:
// sanity/queries/page.ts
import { groq } from 'next-sanity'
export const pageQuery = groq`
*[_type == "page" && slug.current == $slug][0]{
title,
"author": author->{name, bio}
}
`
// types/sanity.ts (generated)
export type PageQueryResult = {
title: string
author: { name: string; bio: string } | null
}The author field is typed as an object, not a reference stub. If you forget the ->, TypeGen will infer {_type: 'reference', _ref: string} and your editor will yell when you try to access author.name.
When to use a separate query
There's one case where I still split queries: when the referenced data is stable and you want to cache it separately at the edge. For example, site-wide navigation or footer data that rarely changes.
I'll fetch that in a separate server component with a long revalidate tag, then pass it as a prop. The navigation query runs once per deploy, not once per page render. But for page-specific references like authors or related posts, always inline.
Measuring the impact
I track this in Vercel's function logs. Look for the "duration" field in your serverless function invocations. Before inlining references, I see 400–600 ms durations on content-heavy pages. After, 150–250 ms. The difference is pure network latency from sequential fetches.
If you're using PPR (partial prerendering), the inlined projection also reduces the Suspense boundary count because you're not waiting on child fetches. Your shell renders faster and your LCP improves.
I've used this pattern on eight production Sanity + Next.js projects since mid-2024. It's the single change that most consistently cuts TTFB and eliminates the "slow page, fast subsequent navigations" problem you get with waterfalls.
Related posts
All posts →How I keep Sanity image pipelines under 50 kB using LQIP hashes and blur overlays
Apr 29, 2026 · 5 min read
A production pattern for serving low-quality image placeholders from Sanity's metadata API without embedding Base64 strings in HTML or shipping extra dependencies.
How I Model Multi-Locale Navigation in Sanity Without Bloating Page Queries
Apr 28, 2026 · 5 min read
A singleton pattern for i18n navigation that keeps GROQ queries fast and avoids embedding nav arrays in every document fetch.
How I Structure Sanity Schemas to Avoid Query Waterfalls in Next.js
Apr 27, 2026 · 5 min read
Denormalising references and embedding common fields in Sanity schemas cuts server component render time by 40–60%. Here's the pattern I ship.