GROQ Queries in Sanity: From Basics to Advanced Optimization
Picture this: you’re building a headless site with Sanity and Next.js. Your content model has grown—6 document types, complex relationships, dynamic zones. On the very first promo landing page, you hit frustration: N+1 queries, nested fetches, and a “related articles” block that takes 7 seconds to load. We faced this on a major media portal project: 12-second load time, 47 separate requests. The solution was a single GROQ query that cut time to 0.8 seconds. Sound familiar? We’ve handled this more than once.
GROQ (Graph-Relational Object Queries) is not REST, not SQL, not GraphQL. It’s more flexible than REST for complex structures: projections, joins via ->, conditional selections, aggregations—all in one query without N+1 issues. Our team, with over five years of Sanity experience, has tuned dozens of projects, cutting API load by up to three times. In this article, we’ll show you how to write GROQ queries properly, avoid common mistakes, and optimize speed.
Why GROQ Instead of REST or GraphQL?
Sanity offers an HTTP API out of the box, but custom endpoints for every page lead to chaos. GROQ gives you a unified syntax for any retrieval. Compare:
| Criterion | GROQ | REST API | GraphQL |
|---|---|---|---|
| Queries per page | 1 (complex) | 5–15 (multiple) | 1–3 (but N+1 with pagination) |
| Join (resolve) | Built-in -> |
Requires separate queries | Requires batch loading |
| Conditional projections | Built-in _type == "..." => |
None, must filter client‑side | Yes, via fragments |
| Aggregations (count, unique) | Built-in functions | None, need server hooks | Yes, but more complex |
GROQ is 2–3 times faster when fetching pages with multiple block types—proven on our projects.
How to Properly Execute Join Queries in GROQ?
Joins in GROQ are done with the resolve operator ->. It replaces relational JOINs and lets you pull related documents without extra queries. Step by step:
- Identify the reference field (e.g.,
authorof typereference). - Use
->after the field:author->{name}. - Limit fields with a projection to avoid loading unnecessary data.
A single query returns posts together with author data:
*[_type == "post"]{ title, "author": author->{name, "avatar": image.asset->url} } Limit resolve depth—two to three levels is enough in practice.
Case Study: Optimizing Blog Load Time
A client came to us with a problem: their blog page on Sanity + Next.js loaded in 12 seconds. We found that for the article list, 47 separate queries were executed (each post fetched author, categories, related articles, and metadata). The solution was a single GROQ query with resolve and pagination:
*[_type == "post" && status == "published"] | order(publishedAt desc) [$start...$end] { _id, title, "slug": slug.current, publishedAt, "author": author->{ name, "avatar": image.asset->url }, "categories": categories[]->{ title, "slug": slug.current }, "excerpt": string::slice(pt::text(body), 0, 200) } count(*[_type == "post" && status == "published"]) Result: 1 query, 0.8 seconds instead of 12. Plus a bonus—total article count for pagination. We build such optimizations into our standard query set.
What's Included in GROQ Query Setup
We offer turnkey development—from audit to documentation. The package includes:
- Analysis of your content model and identification of bottlenecks (N+1, redundant queries)
- Design of universal queries for 4–6 page types: landing pages, lists, detail cards, search
- Implementation with TypeScript typing—each query wrapped in a groq tag returning the correct type
- Optimization via projections, resolve, and aggregations—minimal payload
- Integration with Sanity Vision for debugging and testing
- Team training—documentation and templates handed over
The standard set covers basic syntax, parameterized queries, Portable Text, pagination, full-text search with Algolia (if needed), and reverse lookups (refs). All tested on 30+ projects.
Common GROQ Writing Mistakes
- String concatenation instead of parameters—risk of injection, no caching. Always use
$variable. - Excessive resolve depth—more than three levels kills performance. Stick to 2–3.
- Ignoring
defined()when checking fields—fields can be null in Sanity. - Missing pagination on lists over 100 items. Use
[$start...$end]. - Forgetting the count—return both array and count simultaneously.
Example: Full Query Set (with Typing)
import { createClient } from '@sanity/client' import { groq } from 'next-sanity' const postQuery = groq` *[_type == "post" && slug.current == $slug][0] { _id, title, "slug": slug.current, publishedAt, body, "author": author->{ name, "image": image.asset->url }, "relatedPosts": *[_type == "post" && references(^.categories[]._ref) && _id != ^._id] | order(publishedAt desc) [0...3] { title, "slug": slug.current } } ` type PostResult = { _id: string title: string slug: string publishedAt: string body: any[] author: { name: string; image: string } relatedPosts: { title: string; slug: string }[] } const post = await client.fetch<PostResult>(postQuery, { slug: params.slug }) Full GROQ documentation is available on the official Sanity site. If you want to speed up development—our engineers are ready to help with setup. Contact us to discuss your project and find the optimal solution. Get a consultation from a Sanity engineer.
Timeline and Cost
Developing a set of queries takes one to three days depending on model complexity. Cost is determined individually. We don’t hide numbers: reach out—we’ll assess your project in one business day. We guarantee the queries will be optimized for Core Web Vitals and won’t cause N+1 issues.







