Type something to search...
GROQ Cheat Sheet: Essential Queries for Beginners

GROQ Cheat Sheet: Essential Queries for Beginners

A GROQ cheat sheet is a quick reference for the query patterns you use most in Sanity. Nearly every query follows the same shape: start with * (all documents), add a filter in square brackets such as *[_type == "post"], optionally sort with | order(publishedAt desc), slice with [0...10], and finish with a projection in curly braces that picks the fields you want, like { title, "slug": slug.current }. Learn those five parts and you can read or write most GROQ you'll meet.

This page is meant to sit open in a tab while you work. It groups the essential queries by task, with a short note on each, so you can copy a pattern and adapt it. Where a topic deserves more depth, such as projections, dereferencing or pagination, you'll find a link to the dedicated guide. If you're completely new to the language, start with what GROQ is and come back here.

The Anatomy of a GROQ Query

Every example below is a variation on this pattern:

*[_type == "post" && defined(slug.current)] | order(publishedAt desc) [0...10] {
  _id,
  title,
  "slug": slug.current,
  publishedAt
}

Read it left to right:

PartExampleWhat it does
Dataset*Every document in the dataset
Filter[_type == "post"]Keeps documents where the condition is true
Pipe| order(publishedAt desc)Passes results through a function
Slice[0...10]Keeps a range of results
Projection{ title, ... }Shapes each result into the object you want

The order matters. Filters run first, then sorting, then slicing, then the projection. If you slice before sorting, you'll get ten arbitrary documents sorted among themselves rather than the ten newest.

Fetching Documents

All Documents of a Type

*[_type == "post"]

This returns an array of every post document with all of its fields. It's fine for exploring data in Vision but too heavy for production, where you should always add a projection.

A Single Document

Add [0] to get the first match as an object rather than an array:

*[_type == "post" && slug.current == $slug][0]

If nothing matches, the result is null instead of an empty array, which makes "not found" checks simple in your code.

A Document by ID

*[_id == "a1b2c3d4-..."][0]

Several Types at Once

*[_type in ["post", "page", "project"]]

Exclude Drafts

Draft documents have IDs starting with drafts.. If your client uses the raw perspective, filter them out explicitly:

*[_type == "post" && !(_id in path("drafts.**"))]

With current API versions the default perspective is published, so drafts are already excluded and you rarely need this filter.

Filtering

Filters are boolean expressions inside square brackets. Combine them with && (and), || (or) and ! (not).

GoalQuery
Exact match*[_type == "post" && category == "news"]
Not equal*[_type == "post" && status != "archived"]
Field exists*[_type == "post" && defined(mainImage)]
Field missing*[_type == "post" && !defined(mainImage)]
Number comparison*[_type == "product" && price < 50]
Date comparison*[_type == "post" && publishedAt < now()]
Value in list*[_type == "post" && category in ["news", "guides"]]
Array contains value*[_type == "post" && "sanity" in tags]
Boolean field*[_type == "post" && featured == true]
Prefix match*[_type == "post" && string::startsWith(slug.current, "groq-")]

A few things trip people up:

  • Strings use double quotes in GROQ. Single quotes are not valid string delimiters.
  • Comparisons are type-sensitive: price == "50" won't match a number field with the value 50.
  • Missing fields are null: comparing a missing field with anything returns null, which a filter treats as false.

The full guide to filtering and sorting with GROQ goes deeper into operators, date handling and combined conditions.

Sorting and Slicing

// Newest first
*[_type == "post"] | order(publishedAt desc)

// Alphabetical
*[_type == "post"] | order(title asc)

// Multiple sort keys: featured first, then newest
*[_type == "post"] | order(featured desc, publishedAt desc)

// Case-insensitive alphabetical
*[_type == "post"] | order(lower(title) asc)

Slicing selects a range. Two dots include the end index, three dots exclude it:

*[_type == "post"] | order(publishedAt desc) [0...10]  // items 0-9
*[_type == "post"] | order(publishedAt desc) [0..9]    // items 0-9
*[_type == "post"] | order(publishedAt desc) [10...20] // items 10-19

For anything beyond a handful of pages, use filter-based pagination instead of large offsets.

Projections

Projections shape the output. They're where most of your day-to-day GROQ work happens.

*[_type == "post"] {
  _id,
  title,
  "slug": slug.current,
  "imageUrl": mainImage.asset->url,
  "excerpt": coalesce(excerpt, pt::text(body[0..1])),
  "tagCount": count(tags)
}

Key patterns:

  • Plain field: title returns the field under the same name.
  • Renamed or computed field: "slug": slug.current creates a new key from any expression.
  • Spread: ... includes every field, so { ..., "slug": slug.current } adds to the document rather than replacing it.
  • Nested projection: author->{ name, image } shapes a related object.
  • Conditional fields: _type == "video" => { url } adds fields only when the condition is true.

There's a full guide to using projections in GROQ queries, including conditional projections and projecting arrays of mixed types.

References and Joins

The -> operator follows a reference and returns the referenced document:

*[_type == "post"] {
  title,
  "author": author->name,
  "categories": categories[]->title
}

To go the other way and find documents that point to a given document, use references():

// All posts by a specific author
*[_type == "post" && references($authorId)]

// Each author with their posts
*[_type == "author"] {
  name,
  "posts": *[_type == "post" && references(^._id)] { title, "slug": slug.current }
}

The ^ refers to the enclosing document, here the current author. For the full set of patterns, read how to dereference documents in GROQ.

Counting and Aggregating

// Total number of posts
count(*[_type == "post"])

// Several counts in one request
{
  "posts": count(*[_type == "post"]),
  "featured": count(*[_type == "post" && featured == true]),
  "authors": count(*[_type == "author"])
}

// Average product price
math::avg(*[_type == "product"].price)

The second example shows a useful trick: a query doesn't have to start with *. You can build an object at the top level and fill each key with its own sub-query, so one request returns everything a page needs.

Searching Text

// Word or prefix match, case-insensitive
*[_type == "post" && title match "groq*"]

// Search several fields
*[_type == "post" && [title, pt::text(body)] match $term]

// Rank results by relevance
*[_type == "post"]
  | score(boost(title match $term, 3), pt::text(body) match $term)
  | order(_score desc)
  { title, _score }
  [_score > 0]

match works on whole words and prefixes, not arbitrary substrings. Full-text search queries in GROQ explains tokenisation, wildcards and ranking.

Parameters

Never build queries by joining strings with user input. Pass values as parameters instead:

*[_type == "post" && slug.current == $slug][0] { title, body }
const post = await client.fetch(query, { slug: 'hello-world' })

Parameters are prefixed with $ in the query and passed as a plain object without the $. They're safer and they let Sanity cache identical query strings.

Running Queries From Your Code

Here's a minimal setup with next-sanity, which wraps @sanity/client:

// src/sanity/client.ts
import { createClient } from 'next-sanity'

export const client = createClient({
  projectId: 'your-project-id',
  dataset: 'production',
  apiVersion: '2025-02-19',
  useCdn: true,
})
// src/sanity/queries.ts
import { defineQuery } from 'next-sanity'

export const POSTS_QUERY = defineQuery(`
  *[_type == "post" && defined(slug.current)] | order(publishedAt desc) [0...10] {
    _id, title, "slug": slug.current, publishedAt
  }
`)
// src/app/blog/page.tsx
import { client } from '@/sanity/client'
import { POSTS_QUERY } from '@/sanity/queries'

export default async function BlogPage() {
  const posts = await client.fetch(POSTS_QUERY, {}, { next: { revalidate: 60 } })

  return (
    <ul>
      {posts.map((post) => (
        <li key={post._id}>
          <a href={`/blog/${post.slug}`}>{post.title}</a>
        </li>
      ))}
    </ul>
  )
}

Wrapping queries in defineQuery lets Sanity TypeGen (npx sanity typegen generate) produce result types for each query, so posts is typed without any manual interfaces.

Quick Reference Table

TaskPattern
All of a type*[_type == "post"]
One document*[...][0]
Sort| order(field desc)
First 10[0...10]
Pick fields{ title, body }
Rename{ "slug": slug.current }
Follow referenceauthor->name
Array of referencescategories[]->title
Reverse lookup*[references(^._id)]
Countcount(*[_type == "post"])
Fallback valuecoalesce(subtitle, title)
Field existsdefined(field)
Word searchtitle match "word*"
Parameterslug.current == $slug

FAQ: GROQ Cheat Sheet

The asterisk represents every document in the dataset. On its own it returns everything, so you almost always follow it with a filter in square brackets to narrow the results to the documents you need.

Add [0] after the filter, for example *[_type == "post" && slug.current == $slug][0]. GROQ returns the first matching document as an object, or null if nothing matches.

Two dots include the end index and three dots exclude it. So [0..9] and [0...10] both return the first ten results. Most people use three dots because the end number then equals the count of items.

Yes. Start the query with an object and give each key its own sub-query, such as a list of posts, a settings document and a count. Sanity returns one JSON object with all the results, which saves round trips.

Use the Vision tool inside Sanity Studio. It lets you run queries against any dataset, set parameters, choose an API version and perspective, and inspect the JSON result before you use the query in your code.

You shouldn't need to, because you shouldn't put user input into the query string at all. Pass it as a parameter with a dollar sign prefix in the query and a matching key in the params object, and Sanity handles it safely.


Conclusion

Most GROQ queries are built from the same five pieces: the * dataset, a filter, a sort, a slice and a projection. Once those feel familiar, the rest of the language is a set of additions you reach for when you need them, such as -> for references, count() and coalesce() for computed values, match for search and $parameters for safe input.

Keep this page handy while you build, test every query in the Vision plugin before shipping it, and always project only the fields your front end uses. When a pattern here isn't enough, the linked guides cover each topic in depth.

Tags :
Share :

Related Posts

Building a Static Site With Astro and Sanity

Building a Static Site With Astro and Sanity

To build a static site with Astro and Sanity, keep Astro's default output: 'static', configure @sanity/astro with useCdn: false, and fetch

Dive Deeper
Common Sanity Mistakes and How to Fix Them

Common Sanity Mistakes and How to Fix Them

The most common Sanity mistakes are using the CDN where you need fresh data, fetching whole documents with ..., leaving slugs unvalidated, ship

Dive Deeper
How to Create a Custom Sanity Plugin

How to Create a Custom Sanity Plugin

To create a custom Sanity plugin, wrap a piece of Studio configuration in definePlugin from the sanity package and add it to the plugins ar

Dive Deeper