Speeding Up Sanity Studio with GROQ‑Powered Server‑Side Filtering and Pagination
Learn how to cut network traffic and improve Studio performance by pushing filtering and pagination to Sanity’s GROQ engine. A practical example, trade‑offs, and actionable steps are included.
23 Sept 2025, 02:48 UTC

Problem: Too Many Round‑Trips in Studio
When building a content‑rich application with Sanity Studio, developers often fetch all documents of a type and then filter or paginate them on the client. This approach pulls large JSON blobs over the network, increases load times, and can overwhelm the browser when the dataset grows. The core issue is that filtering and pagination happen after the data has already been transferred from the Sanity backend.
Thesis: Push Filtering & Pagination into GROQ
Sanity’s GROQ query language lets you perform filtering, sorting, and projection on the server. By composing a single query that returns only the documents you need, you eliminate unnecessary data transfer and let the backend do the heavy lifting. This is especially valuable for infinite‑scroll UIs, dashboards, or any feature that requires a subset of a large collection.
1. GROQ Basics for Server‑Side Filtering
GROQ (Graph‑Relational Object Queries) is a declarative query language that operates directly on Sanity’s document store. A typical query looks like this:
*[_type == "post" && "news" in categories[]->slug.current] {
title,
publishedAt,
"authorName": author->name
}
- _type filters by schema type.
- categories[]->slug.current dereferences a reference array and checks for a specific slug.
- Projection (the part after the curly braces) selects only the fields you need.
Running this from the Sanity CLI or Studio’s useQuery hook will return a JSON array of matching posts, each containing only title, publishedAt, and authorName. No extra fields are sent.
2. Pagination Options: Skip/Limit vs. Cursor
Sanity supports two common pagination styles:
Skip/Limit
Append ?skip=10&limit=5 to a GROQ URL or use the skip and take modifiers in Studio:
*[_type == "post"] | order(publishedAt desc)[skip 10 .. skip 15] {
title,
publishedAt
}
- skip moves the cursor forward by a set number of documents.
- limit/take defines the page size.
- Good for simple lists but can suffer from "page skew" if documents are added or removed between requests.
Cursor‑Based Pagination
Cursor pagination uses a stable value (e.g., _id or publishedAt) to continue from the last item fetched:
*[_type == "post" && _id > "lastSeenId"] | order(_id asc)[0 .. 4] {
_id,
title
}
- More resilient to concurrent data changes.
- Requires storing the last seen cursor value on the client.
3. Integrating Server‑Side Queries into Studio
Use the useClient hook from @sanity/react‑sdk to run a GROQ query inside a React component:
import {useClient, useQuery} from "@sanity/react‑sdk";
const client = useClient({
apiVersion: "2024-10-14", // use a fixed API version
projectId: <YOUR_PROJECT_ID>,
dataset: <YOUR_DATASET>
});
const query = `*[_type == "post" && "news" in categories[]->slug.current] | order(publishedAt desc)[0 .. 9] {title, publishedAt, "authorName": author->name}`;
const {data, error, isLoading} = useQuery(query, client);
- Permissions: The client must have read access to the requested fields. If your dataset is private, use a signed token.
- Placeholders: Replace
<YOUR_PROJECT_ID>and<YOUR_DATASET>with actual values. - Checks: Verify that
datais an array and that each object contains the expected keys. - Risk: A malformed query can return an error or empty result; always test the query in the Sanity CLI first.
4. Concrete Example: Paginated News Feed
Suppose you need a paginated list of the latest news posts, 5 per page, sorted by publishedAt descending. The following steps illustrate the full flow:
- Define the query with skip/limit:
- Run the query via
useQueryas shown in the previous section. - Render the list:
- Add navigation controls that adjust
currentPageand trigger a re‑run of the query.
const pageSize = 5;
const currentPage = 2; // zero‑based
const skip = currentPage * pageSize;
const query = `*[_type == "post" && "news" in categories[]->slug.current] | order(publishedAt desc)[${skip} .. ${skip + pageSize - 1}] {title, publishedAt, "authorName": author->name}`;
{data?.map(post => (
<div key={post._id}>
<h3>{post.title}</h3>
<p>Published on {new Date(post.publishedAt).toLocaleDateString()} by {post.authorName}</p>
</div>
))}
By keeping the query server‑side, the component only receives 5 documents per request, drastically reducing payload size.
Trade‑Offs & Limitations
- Complexity of Queries – Deep path traversals or many joins can slow down GROQ execution. Keep projections shallow when possible.
- Pagination Skew – Skip/limit can produce duplicate or missing items if the underlying dataset changes between requests. Cursor pagination mitigates this but adds client‑side state.
- Version Compatibility – GROQ syntax is stable in Sanity v1.x, but older projects may use legacy syntax. Always test queries after upgrading the SDK.
- Client Permissions – Ensure that the API token used by the Studio client has read access to all referenced fields; otherwise the query will fail with a permission error.
Actionable Takeaway
To make your Sanity Studio faster and more scalable:
- Move filtering and projection into a single GROQ query.
- Use skip/limit for simple lists or cursor pagination for data that changes frequently.
- Test queries in the Sanity CLI before embedding them in Studio.
- Monitor query performance with the
performancetab in the Sanity CLI or by inspecting network timing in the browser. - Keep your API version pinned and update only when you’re ready for a controlled release.
By following these steps you reduce network load, cut rendering time, and provide a smoother experience for content editors and end‑users alike.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.