Concepts & Strategy6 min read

Top 7 GROQ Patterns Every Sanity Developer Should Know in 2026

You wrote a GROQ query that works, ships, and then quietly over-fetches half your dataset on every page load. Or you chained three `->` dereferences, hit an N+1 pattern you did not see coming, and watched your build times climb.

Published September 4, 2026

You wrote a GROQ query that works, ships, and then quietly over-fetches half your dataset on every page load. Or you chained three `->` dereferences, hit an N+1 pattern you did not see coming, and watched your build times climb. The GROQ that gets you through a prototype is rarely the GROQ that survives a real content model with references, arrays, weak links, and search. That gap is where most Sanity projects lose their edge.

GROQ (Graph-Relational Object Queries) exists to close that gap. It describes exactly the information an application needs, joining documents and returning only the exact fields you requested, in a single round trip. This matters because Sanity is the Content Operating System for the AI era, the intelligent backend where your content model, your queries, and your retrieval all live against one queryable Content Lake instead of a stack of glued-together services.

This article ranks seven GROQ patterns worth internalizing for 2026, from the query anatomy every project depends on through dereferencing, projections, slicing, and the hybrid text search that blends keyword and semantic ranking. Each one is a copy-paste-modify snippet, not a lecture, with the trade-off that tells you when to reach for it and when not to.

1. Query anatomy: filter, order, project, slice

Every GROQ query you will ever write is the same four moves in the same order, and internalizing that shape is the single highest-leverage thing you can do. A query starts with `*`, which means every document in the dataset. Then a filter in square brackets narrows it: `*[_type == "post" && defined(publishedAt)]`. Then the pipeline flows left to right through `| order(publishedAt desc)`, a projection in curly braces that names the exact fields you want, and finally a slice like `[0...12]` that bounds the result set.

The pitch is precision. Unlike a REST endpoint that hands you a fixed payload, GROQ describes exactly the information the application needs and returns only that. The projection is where over-fetching goes to die: if your card component needs `title`, `slug`, and a `publishedAt`, you project those three and nothing else, and the response is small on the wire and cheap to render.

Where it fits poorly: developers new to GROQ often skip the slice, run `*[_type == "post"]` unbounded, and are surprised when a growing dataset degrades. Always bound your reads. A concrete example that pulls the whole anatomy together:

```groq
*[_type == "post" && "featured" in tags]
| order(publishedAt desc)
[0...6]{
_id,
title,
"slug": slug.current,
publishedAt
}
```

Filter, order, project, slice. Read any query in your codebase against that skeleton and it stops looking like syntax and starts looking like a sentence. This is the pattern the other six build on, which is why it ranks first.

2. Dereferencing references with the -> operator

A reference in Sanity is a link from one document to another, and the `->` operator dereferences it in place so you can pull fields off the target without a second query. This is the pattern that separates GROQ from fetching flat records and stitching them together in application code. Where a REST or fixed-resolver approach makes you fire follow-up requests for each related record, GROQ walks the graph inside one round trip.

The pitch is that joins are cheap and legible. If a `post` has an `author` reference, you write `author->{name, "avatar": image.asset->url}` and you get the author's name and a resolved asset URL inline. You can chain hops: to find products with a given feature, you chain product to productFeature and match on the feature's id. The schema shows each hop, not the full path, so knowing the shape of your data matters as much as knowing the types.

Where it fits poorly: unbounded dereferencing across large arrays of references can fan out. Project only the fields you need off the target, and be deliberate about hard versus weak references. A standard reference is hard, meaning the target cannot be deleted while it is referenced; a weak reference is marked with `_weak: true` and lets the target be removed, which you resolve defensively with a fallback.

```groq
*[_type == "post" && slug.current == $slug][0]{
title,
body,
author->{name, role},
categories[]->{title, "slug": slug.current}
}
```

That `categories[]->` walks an array of references and projects each target. One query, the full shape, no N+1.

3. Projections and aliases: return the exact shape

The projection is the part of GROQ that most directly answers the GraphQL comparison, and it earns its ranking because it is where the round-trip savings compound. With GROQ you ask for exactly the shape you need, renaming fields, computing values, and flattening nested references, so the response arrives already matching what your component expects. No resolver map to maintain, no over-fetch, no post-processing step.

The pitch is that the query owns the response contract. You alias a nested value up to the top level with a string key: `"authorName": author->name`. You reshape arrays, coalesce fallbacks with `coalesce(subtitle, title)`, and conditionally include fields. The projection is not a filter on a fixed payload; it is the payload definition itself, written where you consume it.

Where it fits poorly: projections can grow into sprawling, deeply nested objects that are hard to review. Keep them shaped like the component that reads them, and lean on TypeGen to codegen TypeScript types from your queries so the shape stays honest across refactors, a real developer-experience win when a schema field moves.

```groq
*[_type == "product" && defined(price)]{
_id,
"name": title,
"price": coalesce(salePrice, price),
"inStock": count(*[_type == "inventory" && references(^._id) && qty > 0]) > 0,
"heroUrl": hero.asset->url
}
```

That `^._id` reaches up to the parent scope for a correlated subquery, computing an `inStock` boolean inline. Contentful and Hygraph return GraphQL shapes fixed by their resolvers; here the shape is yours, defined in the query, resolved in one trip against Content Lake.

4. Filtering, matching, and correlated subqueries

Filters are where GROQ does the work that has to hold, the predicates that are non-negotiable regardless of ranking or relevance. This pattern ranks in the middle because it is unglamorous and constant: every real query filters on `_type`, on state like `defined(publishedAt)`, on relationships, and increasingly on structural constraints a fuzzy search alone would miss. A query carries a real structural component, a category, a version number, an "in stock" flag, that has to be respected exactly.

The pitch is expressiveness inside the filter bracket. You get equality and comparison, `in` for membership, `match` for wildcard string matching, `references()` to filter documents that point at a given id, and boolean composition. You can also run correlated subqueries with `^` to reach the enclosing scope, so a document can be filtered or annotated by counting or checking related documents.

Where it fits poorly: `match` is a keyword-and-wildcard tool, not semantic understanding. It falls over the moment the user says "something like X" or "the cozy one" or anything that lives in vibes, not fields. For that you reach for pattern seven. Use `match` for literal and prefix matching, not intent.

```groq
*[
_type == "article"
&& status == "published"
&& $now > publishAt
&& count((categories[]->slug.current)[@ in $selected]) > 0
&& title match $term + "*"
]{ _id, title }
```

That combines a state check, a scheduled-publish gate, a reference-membership test, and a prefix match, the kind of compound predicate that keeps governed editorial content correct rather than merely relevant.

5. Hybrid text search: keyword and semantic in one query

The most advanced pattern, and the one that most clearly shows why retrieval belongs in the content backend, is hybrid text search. Pure structured query falls over on "the cozy one"; pure vector search ignores the constraints that have to hold. The discipline is hybrid: keyword search (BM25) for literal matches, embeddings for semantic ranking, and structured predicates for the filters that cannot bend. Anthropic's contextual retrieval research measured this directly: contextual embeddings cut top-20 retrieval failures by 35%, adding contextual BM25 took that to 49%, and adding reranking on top brought it to 67%. No single layer alone was enough.

GROQ expresses all three in one query. The predicates filter what has to hold, then `| score()` blends a weighted keyword match with semantic similarity, then you order by `_score` and slice:

```groq
*[
_type == "product"
&& category == $category
&& price < $maxPrice
&& stockLocation == $warehouse
]
| score(
boost([title] match text::query($queryText), 2),
text::semanticSimilarity($queryText)
)
| order(_score desc)
[0...10]{
_id, title, price,
"stock": stockLocation->{name, available}
}
```

The `boost(..., 2)` weights title hits because a title match matters more. What Content Lake handles, and what building this yourself would cost, is index freshness: incremental indexing, re-embedding on change, deletion handling, and backfill for schema changes. Wire retrieval into the backend and the freshness problem stops being something you maintain. "We have embeddings" is not a retrieval strategy. Reach for this pattern when intent genuinely exceeds fields, not by default.

Ready to try Sanity?

See how Sanity can transform your enterprise content operations.