Optimizing Data Fetching with GROQ Projections and Pagination
Stop over-fetching data from your headless CMS. Learn how to use GROQ projections to shape your API responses and implement stable pagination for your content feeds.
17 Dec 2025, 06:49 UTC

The Over-fetching Problem in Headless CMS
When building a front end for a headless CMS, developers often face a trade-off: fetch a generic document and filter the data in the browser, or create dozens of specific API endpoints for every single UI component. Fetching entire documents leads to "over-fetching," where the browser downloads metadata, internal IDs, and large blocks of unused text, slowing down page loads and increasing memory usage.
The solution in Sanity is GROQ (Graph-Relational Object Queries). Unlike REST, which gives you a fixed resource, GROQ allows the client to define the exact shape of the response. By using projections and strategic filtering, you can transform a complex graph of referenced documents into a flat, UI-ready JSON object in a single request.
Flattening Data with Projections
In Sanity, documents often reference one another. For example, a post document might contain a reference to an author document. A standard query returning the post would only provide the author's ID, requiring a second network request to get the author's name.
Projections solve this by allowing you to "follow" the reference inline. Using the -> operator, you can reach into the referenced document and pull out only the fields you need.
Example: The Blog Feed Query
Run this query in the Vision plugin within Sanity Studio to see the difference between a raw fetch and a projected fetch. This example assumes a schema with post and author types.
// Fetch the 10 most recent published posts with specific fields
*[_type == "post" && publishedAt != null] | order(publishedAt desc) [0...10] {
title,
"slug": slug.current,
"publishedDate": publishedAt,
"authorName": author->name,
"category": category->title
}
*[_type == "post"]: Filters the Content Lake for documents of type "post".| order(publishedAt desc): Pipes the results into a sorting function.[0...10]: A slice that limits the result to the first 10 items.{ ... }: The projection. It renamesslug.currenttoslugand resolves theauthorreference to just thenamestring.
Handling Pagination: Offset vs. Keyset
Pagination is typically handled via slicing, but the method you choose affects the stability of your feed.
Offset Slicing
Using [0...10] for page one and [10...20] for page two is simple to implement. However, if a new post is published while a user is scrolling, the items shift. The last item of page one may become the first item of page two, causing the user to see a duplicate entry.
Keyset Pagination
For high-frequency publishing environments, use a keyset approach. Instead of a numeric offset, filter by the value of the last item retrieved (usually a timestamp or ID). This ensures that you only fetch items that occur after your current window, regardless of how many new items were added to the top of the list.
// Fetch the next 10 posts older than the last seen post
*[_type == "post" && publishedAt < "2026-10-01T12:00:00Z"] | order(publishedAt desc) [0...10]
Performance Trade-offs and Limitations
While projections are powerful, they are not free. Every -> operator represents a join operation in the Content Lake. If a single query follows dozens of references across hundreds of documents, you may notice increased latency.
| Strategy | Pros | Cons |
|---|---|---|
| Deep Projections | Single request, clean API response. | Can slow down on massive datasets. |
| Denormalization | Instant reads, no joins required. | Data redundancy; harder to update globally. |
Practical Tip: If you find yourself projecting the same author name across thousands of posts in a high-traffic feed, consider denormalizing that specific field (storing a copy of the name directly on the post document) to reduce join overhead.
Verifying Your Implementation
To ensure your queries are optimized, follow these verification steps:
- Payload Audit: Compare the JSON response size of a
*[_type == "post"]query against your projected query. You should see a significant reduction in bytes transferred. - Drift Test: While browsing a paginated list, publish a new document in the Studio. If you see a duplicate item upon clicking "Next Page," you are using offset slicing and should switch to keyset pagination.
- Vision Testing: Always validate the syntax in the Sanity Studio Vision plugin before deploying the query to your production codebase to avoid runtime errors in your frontend client.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.