Stop Over-Fetching: Using GROQ Projections to Shape Sanity Content at Query Time
GROQ projections let you shape Sanity query results server-side — flattening references, generating excerpts, and matching your component props exactly. Here's a worked example plus the trade-offs.
22 Aug 2026, 12:26 UTC

Your frontend needs a title, a slug, and one hero image. Your Sanity query returns the entire document — body text, SEO fields, references, revision metadata — and then your component picks three fields out of the pile. Multiply that across a listing page rendering forty cards and you're shipping kilobytes of JSON nobody reads.
GROQ projections fix this at the source. Instead of fetching documents and trimming them in application code, you declare the exact shape you want inside the query, and Sanity's content lake returns only that. This post walks through how projections work, a concrete before-and-after example, and the trade-offs worth knowing before you rewrite every query in your project.
What a projection actually is
GROQ (Graph-Relational Object Queries) is Sanity's query language for its hosted content lake. A query has three parts: a filter that selects documents, an optional projection that reshapes each result, and optional ordering and slicing. The projection is the part in curly braces:
*[_type == "post"] {
title,
"slug": slug.current,
"heroUrl": heroImage.asset->url
}Each line inside the braces is either a field name (passed through as-is) or a key-value pair where the value is any GROQ expression. The -> operator follows a reference — here, from the image field to the asset document it points at — so you can flatten nested data into exactly the shape your component expects.
Two things make this more powerful than a SQL SELECT list. First, projections can contain arbitrary expressions: conditionals, string concatenation, subqueries, even counts of related documents. Second, the shaping happens server-side, so the transformation cost never reaches your client.
A worked example: listing page cards
Say you have a post schema with a body in Portable Text, an author reference, and an array of category references. A typical first-pass query looks like this:
// Before: fetch everything, trim in JS
*[_type == "post"] | order(publishedAt desc) [0...20]That returns full documents, including the entire body. The card component then reads post.title, post.author.name, and post.categories[0].title — which only works because the client SDK sometimes auto-resolves references, and breaks confusingly when it doesn't.
The projection version is explicit and lean:
*[_type == "post" && defined(slug.current)]
| order(publishedAt desc) [0...20] {
_id,
title,
"slug": slug.current,
"authorName": author->name,
"categoryTitles": categories[]->title,
"excerpt": pt::text(body)[0...160],
"heroUrl": heroImage.asset->url,
publishedAt
}A few things to notice. pt::text(body) converts Portable Text blocks into a plain string, and the slice keeps it to 160 characters — a real excerpt, computed in the query, with no client-side block parsing. categories[]->title maps over the array and dereferences each entry, returning a flat array of strings. The filter's defined(slug.current) guard excludes drafts that would produce broken links, which is easier to enforce here than in every component.
You can verify any of this before touching application code: open the Vision plugin in Sanity Studio (usually at /vision in your Studio, or installable as @sanity/vision), paste the query, and inspect the returned JSON. You need read access to the dataset; no write permissions are involved, so experimentation is safe. If the output shape matches your component's props, you're done.
Projections as a contract with your frontend
The less obvious benefit is that projections become a typed contract. If you generate TypeScript types from your queries — with Sanity's official type generation tooling (sanity typegen in recent CLI versions) or a community tool — the generated types reflect the projected shape, not the raw schema. Rename a field in the projection and the compiler flags every component that still uses the old name. That feedback loop is much tighter than discovering a broken reference resolution at runtime.
A practical pattern: co-locate each query with the component or page that consumes it, and let the projection mirror the props interface one-to-one. When a designer asks for the author's avatar on cards, you add one line to the projection and the type system tells you the prop is now available.
Trade-offs worth knowing
Projections are not free of friction. The syntax has a learning curve if your team thinks in SQL — dereferencing with ->, the distinction between [] filtering and []-> mapping, and scoping rules inside nested projections all trip people up initially. Budget time for the Vision tool to become a habit.
Second, heavily customized projections can drift from your schema. If you compute an excerpt in five different queries with five different lengths, you've created five sources of truth. Extract shared projection fragments into constants in your codebase (GROQ supports string interpolation when you build queries in JS/TS) so the shaping logic lives in one place.
Third, remember that projections run per-request against the content lake. Deeply nested dereferences across many references are convenient but add query cost; for very hot endpoints, check response times in your API logs and consider whether a flatter schema or a CDN-cached response (useCdn: true on the client for published content) matters more than query elegance.
Where to start
Pick your highest-traffic listing query — the one rendering cards or search results — and rewrite it with an explicit projection. Run both versions in Vision, compare the response sizes, and wire the new one up behind your existing component. If the props line up and the payload shrinks, you have your template for the rest of the codebase. The Sanity docs' GROQ reference covers the full expression language when you need conditionals, joins, or scoring; the patterns above cover the majority of real frontend queries.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.