Sanity GROQ: Practical Query Patterns for Content Retrieval
GROQ lets you filter, join, and project Sanity content in a single request. This guide covers the essential query pipeline, reference expansion, Portable Text handling, parameterization, caching, and the common mistakes that cause timeouts or oversized responses.
12 Sept 2026, 08:31 UTC

Why GROQ matters for Sanity projects
Sanity's GROQ (Graph-Relational Object Queries) is the primary way to fetch and shape content from the document store. Unlike REST endpoints that return fixed shapes, GROQ lets you filter, project, join, and transform data in a single request. The practical payoff: fewer round trips, smaller payloads, and the ability to tailor responses for each frontend view without backend changes.
Core mechanics: filter, order, paginate, project
A typical blog index query demonstrates the standard pipeline:
*[_type == 'post' && defined(slug.current)]
| order(publishedAt desc)
[0...10]
{
_id,
title,
slug,
publishedAt,
'author': author->{name, image},
mainImage
}
Breakdown:
*[_type == 'post' ...]— starts from all documents, filters by type first (uses internal index).defined(slug.current)— excludes drafts or incomplete entries.| order(publishedAt desc)— sorts on an indexed field; sorting on non-indexed fields degrades on large datasets.[0...10]— pagination slice; always bound result sets to avoid 10 MB response limit.- Projection
{...}— selects only needed fields. The'author': author->{name, image}syntax follows the reference (->) and inlines a subset of the author document.
Reference expansion and parent scope
References store only {_ref: 'id', _type: 'reference'}. Use -> to expand:
'categories': categories[]-> {_id, title}
Inside a nested projection, ^ reaches the parent scope. Example: pulling the post's slug into each category object:
'categories': categories[]-> {
_id,
title,
'postSlug': ^.slug.current
}
This avoids a second query when the UI needs the post URL alongside category labels.
Portable Text handling
Block content (Portable Text) arrives as an array of block objects with markDefs and children. Two common needs:
- Raw blocks for rendering: query
body[] {..., markDefs[]->{...}}then serialize with@portabletext/reactor@sanity/block-content-to-react. - Plain-text excerpt: use
pt::text(body)(requires API version 2021-03-25+). Example:'excerpt': pt::text(body)[0...200].
Set apiVersion: '2024-01-01' (or a recent date) in the client config to guarantee access to current functions.
Parameterized queries prevent injection
Never interpolate user input directly. Use parameters:
const query = `*[_type == $type && $slug in slug.current] { title, slug }`;
const params = { type: 'post', slug: 'my-post' };
const data = await sanityClient.fetch(query, params);
The JavaScript client (sanityClient.fetch(query, params)) handles encoding. This pattern also enables CDN caching per unique parameter set.
Performance guardrails
- 10-second timeout and 10 MB response limit on the HTTP API. Complex joins or unbounded projections hit these.
- Lead with
_typefilters; they use the internal type index. Avoid*[]scans. - Leverage indexed fields:
slug.current,publishedAt, and custom indexes created via the API. - Paginate aggressively:
[0...50]or[0...100]for lists; use cursor-based pagination for infinite scroll. - Denormalize read-heavy fields: copy
author.nameonto the post document if the author object is rarely needed in full. Reduces join depth and latency.
Caching behavior
GET requests to the CDN (apicdn.sanity.io) are cached for 30 seconds by query string. Mutations invalidate cache, but propagation takes ~30 seconds. For immediate consistency after writes:
- Use the non-CDN endpoint (
api.sanity.io), or - Send the query via POST (bypasses CDN cache), or
- Add
?cache=neverto the GET request.
Common mistakes and fixes
| Mistake | Symptom | Fix |
|---|---|---|
Projecting a reference without -> | Only {_ref, _type} returned | Add -> and a sub-projection: 'author': author->{name} |
Using [] on a non-array field | Returns null instead of empty array | Wrap with coalesce(field[], []) or field[] | select(defined(@)) |
| Ordering on non-indexed field | Query slows as dataset grows | Add index via API or denormalize a sortable field |
Deep -> chains | Timeout or high latency | Limit expansion depth; denormalize frequently accessed data |
| Assuming full-text search in GROQ | No match or contains on body text | Integrate Sanity Search API (beta) or external engine (Algolia, Meilisearch) via webhooks |
Verification checklist
- Test queries in Sanity Vision (Studio → Vision tab) with your dataset and target
apiVersion. - Click Explain in Vision to see execution plan; watch for full scans.
- Inspect
x-sanity-query-msheader in API responses for latency. - Validate Portable Text rendering with
@portabletext/reactusing actual queried block data. - Monitor the API dashboard (
manage.sanity.io) for query latency, error rates, and cache hit ratios.
Limitations to plan for
- No native full-text search — requires external integration.
- Reference expansion depth is limited; deeply nested
->chains degrade performance. - Array filtering inside projections (
body[style == 'h2']) only works on arrays of objects with those fields. On mixed arrays, useselect()or conditional projections. - API version locks function availability. Pin
apiVersionin client config; test after Sanity upgrades.
GROQ's strength is shaping exactly what the frontend needs in one request. Start with the minimal projection, add joins only when the UI requires them, and verify each query in Vision before shipping. That discipline keeps latency predictable and payloads small as the content graph grows.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.