Stop Over-Fetching: Using GROQ Projections to Ship Lighter Sanity Queries
Bare Sanity queries over-fetch and force extra round-trips for references. Learn how GROQ projections and the -> dereference operator let you shape exactly the JSON your components need, server-side.
09 Oct 2026, 05:19 UTC

The problem: your frontend is drowning in fields it never renders
If you query Sanity's Content Lake with a bare *[_type == "post"], you get everything: every field, every draft-adjacent system property, every reference as a raw { _ref: "..." } stub. Then your frontend throws most of it away and issues follow-up requests to resolve the references. You pay for that twice: once in payload size, once in extra round-trips.
GROQ — Sanity's Graph-Relational Query Language — exists to fix exactly this. The thesis of this post: write the shape of the JSON your component needs directly into the query, and let the Content Lake do the filtering, joining, and renaming server-side.
Projections: ask for only what you render
A projection is the part of a GROQ query in curly braces. Instead of returning whole documents, you list the fields you want — and you can rename them while you're at it:
*[_type == "post" && defined(slug.current)]{
"id": _id,
title,
"slug": slug.current,
excerpt,
"publishedAt": coalesce(publishedAt, _createdAt)
}Three things are happening here. The filter defined(slug.current) drops documents missing a slug before anything is returned. The "id": _id syntax renames a system field into something your component expects. And coalesce() gives you a fallback value without client-side null checks. The result is a flat array of objects shaped exactly like your props — no mapping layer, no data.map(p => ({...})) cleanup in your page code.
Joins without round-trips: dereferencing with ->
References in Sanity are stored as pointers. Fetching a post gives you author: { _ref: "abc123" }, not the author. The -> operator resolves that pointer inside the same query:
*[_type == "post" && slug.current == $slug][0]{
title,
body,
"author": author->{
name,
"avatarUrl": avatar.asset->url
},
"categories": categories[]->title
}This one query walks three levels: post → author → the author's image asset URL, plus an array of category references flattened to plain strings via categories[]->title. In a REST or naive GraphQL setup this would be multiple requests or a resolver chain. Here it's one round-trip, and the response contains nothing the page doesn't use.
To try this yourself, open the Vision plugin inside Sanity Studio (it's the built-in GROQ playground), paste the query, and add {"slug": "your-real-slug"} in the params panel. Vision runs against your actual dataset, so it's the fastest way to iterate before wiring a query into code. No special permissions needed beyond Studio access — Vision is read-only unless you explicitly use it for mutations.
Server-side filtering beats client-side filtering
GROQ filters run at the Content Lake level, which supports more than equality checks: array membership ("news" in categories[]->slug.current), pattern matching with match(), ordering, and slicing:
*[_type == "post"
&& publishedAt < now()
&& "featured" in tags
] | order(publishedAt desc)[0...10]{
title, "slug": slug.current
}The [0...10] slice returns the first ten results after ordering — GROQ's range syntax is inclusive on both ends, which trips up people expecting JavaScript-style slicing. Doing this server-side means the network payload scales with what you display, not with how much content exists.
The trade-offs you should know about
Deep dereferencing has a cost. Chaining -> across many references in a large dataset increases query time and response size. If you find yourself dereferencing four levels deep across hundreds of documents, that's a signal to restructure either the query or the schema — denormalizing a frequently-read field (like an author name copied onto the post) can be the better engineering call.
Projections create an implicit contract with your frontend types. If you rename _id to id in the query and later change the schema or the projection, TypeScript won't catch it unless you generate types from your queries. Treat GROQ queries as part of your API surface and review them when schemas change.
GROQ is proprietary. Unlike SQL or GraphQL knowledge, GROQ syntax transfers only within the Sanity ecosystem. The flip side is that it's tightly integrated with the Content Lake's document model, which is why joins and projections feel so direct.
A practical way to verify the win
Before and after rewriting a query, compare payloads. Run your old query and your projected query in Vision, or log the response size in your frontend's network tab. You should see a meaningful drop in bytes and — if you replaced reference-chasing with -> — fewer total requests. If the projected query isn't smaller or faster, the projection probably isn't the bottleneck and you should look at asset delivery or caching instead. Start with your highest-traffic page's query, tighten it, measure, then move to the next one.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.