How to Query Sanity Content With GROQ for a Faster Front End
GROQ is Sanity‘s query language. A single GROQ query can return a post, its author, and its category names in one request, which is why most teams stop using REST-style expansion after week one. Queries run against the CDN endpoint for published content and the live endpoint for drafts.
The speed difference is real but not magic. You save round trips, not database work. Fetching a list of 20 posts with authors via GROQ is one HTTP call instead of 21.
What Does a Basic GROQ Query Look Like?
Start with the document selector, then parentheses, then a projection.
*[_type == "post"] | order(publishedAt desc)[0...10]{
title,
"slug": slug.current,
publishedAt,
"authorName": author->name
}
Three things to notice. The brackets filter documents, the pipe operators sort and paginate, and the braces define what you actually want back. Anything not listed in the braces never crosses the wire.
How Do I Join Related Documents in One Query?
Use the arrow operator — author->name follows a reference and reads a field on the target document. This is the piece REST cannot do without extra requests.
| Task | GROQ | Cost |
|---|---|---|
| Follow one reference | author->name |
Free, same query |
| Nested reference | author->company->name |
Free, same query |
| Count children | count(*[_type=="comment" && post._ref == ^._id]) |
Extra scan |
| Reverse lookup | *[_type=="post" && references(^._id)] |
Extra scan |
That last pattern is how you get “all posts tagged with this topic” from a topic document. references() is a filter, not a join, so it scans documents — fine at a few thousand docs, painful at a hundred thousand.
How Do I Filter and Paginate Without Breaking Sorting?
Ordering and slicing always go in the pipe section, after the filter:
*[_type == "post" && defined(slug.current)]— skip drafts with no slug.| order(_createdAt desc)— newest first;_createdAtis set by the system, so it always exists.| order(publishedAt desc)[0...12]— page one. For page two use[12...24].
Offset pagination drifts when editors publish between page loads. If that matters, cursor on a stable field: *[_type=="post" && publishedAt < $cursor] and pass the last item’s timestamp as a parameter.
Why Is My Query Slow or Capped?
Two usual suspects. First, querying the live API instead of the CDN: live is uncached and costs more units. Swap api.sanity.io for apicdn.sanity.io and published reads drop to near-instant.
Second, unbounded array projections. body on a long post can be 200 KB of portable text. Add body[]{_type, children[0...1]} or simply omit body from list queries and fetch it only on the detail page.
Sanity meters API requests on the free plan at 100,000 per month — 2,000 requests for a list view costs 2,000 units, and a misconfigured client component re-rendering on every keystroke gets there in a week.
How Should I Cache GROQ Results in Next.js or Astro?
Use incremental static regeneration with a tag-based revalidation webhook. In Next.js App Router, fetch with { next: { tags: ['post'], revalidate: 3600 } } and have a Sanity webhook hit your /api/revalidate route on publish. Pages stay static, content updates within seconds of publishing.
Astro users get the same with getStaticPaths plus a redeploy hook, but a full rebuild on every publish stops scaling past a few hundred pages.
Who Should Not Bother With GROQ?
If your content model is flat — one page type, no references — plain fetch() against the REST export is simpler and nobody on the team has to learn a query language. GROQ pays off specifically when content is relational. Teams that copy a 15-line GROQ query without understanding the arrow operator end up debugging empty arrays for an afternoon.
Quick Summary
- GROQ returns nested references in one request using the
->operator. - Projections limit payload size — list queries should not include the full body.
- Query
apicdn.sanity.iofor published content; the live API is uncached. - Offset pagination drifts; use cursor pagination for feeds.
- The free plan allows 100,000 API requests per month, so cache aggressively.
