Optimizing Content Payloads with GROQ Projections
Stop over-fetching data from Sanity. Learn to use GROQ projections to transform content on the server, reducing payload size and improving app performance.
26 May 2026, 21:16 UTC

A common first pass on a Sanity.io project fetches whole documents and trims them in the browser. A list page pulls every post with its full Portable Text body, author documents, and image metadata, then renders a title and a date. That is over-fetching: bigger downloads, slower first renders, and more bandwidth against your project's API quota. The fix is built into Sanity's query language: GROQ projections reshape the JSON on the server so the API returns exactly the fields your component renders.
Why Sanity queries need explicit joins
Sanity stores content in the Content Lake, a document-oriented store exposed over an API. Documents link to each other with references — a post document holds an author's _id, not the author's data. A plain fetch returns only that ID. GROQ (Graph-Relational Object Query Language) resolves the link when you dereference it with ->, which works like a join in SQL. Doing this in one query avoids the N+1 pattern, where a frontend fetches a list and then one extra request per item.
One architectural detail matters here: the Studio (editing interface) and the Content Lake (API) are separate systems. Renaming a field in a schema does not migrate data by itself — queries that still project the old field name start returning null. Keep schema changes and query changes in step.
Projections define the response shape
A projection is the {...} block at the end of a GROQ query. Inside it you map source fields to output keys, derive new values, and resolve joins. Two details make projections especially useful with Sanity content:
- Portable Text is Sanity's JSON format for rich text: an array of blocks, each with child spans and marks. It is flexible and portable but verbose. The
pt::text()function flattens blocks into a plain string before the response leaves the API. - Computed fields: any value in a projection can be derived on the fly, such as concatenating first and last names into one
fullNamestring.
Worked example: a lean list query
Assume a post schema with title, publishedAt, a Portable Text body, and an author reference to a document with name and surname fields. For a list view you need the title, date, author name, and a short excerpt:
*[_type == "post"] | order(publishedAt desc) {
title,
publishedAt,
"author": author->{
"fullName": name + " " + surname
},
"excerpt": pt::text(body)[0...150]
}
What each part does:
*[_type == "post"]selects all post documents; filtering on_typeis the standard way to scope a query. For a real list page, cap results with a range such as[0...20].author->{...}follows the reference and projects only the derived name, so the rest of the author document never leaves the API. If a post has no author, this resolves tonullrather than erroring.pt::text(body)turns the Portable Text array into one plain string, and the[0...150]range keeps the first 150 characters. The client receives a short string instead of the whole body.
The query is designed to return one object per post with exactly these keys. The values below are placeholders — confirm the shape against your own dataset:
{
"title": "...",
"publishedAt": "2026-03-14T09:00:00.000Z",
"author": { "fullName": "..." },
"excerpt": "first 150 characters of body text"
}
How to run and verify it
The quickest check is the Vision plugin in a local Sanity Studio (this syntax is stable across the Sanity v3 line and its bundled GROQ). Vision runs the query against your dataset with your Studio login, so no extra token is needed for data you can already read. You can also call the HTTP API from a terminal, replacing the placeholders:
curl "https://<projectId>.api.sanity.io/v2021-10-21/data/query/<dataset>?query=*%5B_type+%3D%3D+%22post%22%5D"
Public datasets answer without credentials; private datasets need a token with read access. To measure the win, run the bare query *[_type == "post"] and the projected version against the same dataset and compare download sizes in your browser's network panel, or with curl -w "%{size_download}".
Trade-offs and limits
- Join depth costs latency. Every
->is another lookup, and deeply nested dereferences get slow as datasets grow. Keep joins shallow and project few fields. - Projections couple queries to your schema. Rename
nametofirstNamewithout updating the query andfullNamesilently becomesnull. There is no built-in migration for this; update schema, data, and queries together. If a list page repeats the same join on every request, a common Sanity pattern is to copy a small field, like an author display name, onto the parent document at edit time. - Network latency still applies. The Content Lake is a remote API, so pair tight queries with client-side caching or Sanity's realtime listeners for repeat views.
Takeaway
Before reaching for client-side data munging, write the projection that outputs the exact object your component needs, validate it in Vision, and compare payload sizes against the unprojected fetch. GROQ queries are read-only, so iterating is safe — the worst a bad projection does is return the wrong shape, not damage data.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.