GROQ Query Language Reference
GROQ (Graph-Relational Object Queries) is the query language used by CellCMS to fetch, filter, and reshape content. If you have used Sanity, you already know GROQ -- CellCMS implements the same syntax and semantics, translated to PostgreSQL under the hood.
This reference covers every feature supported by the CellCMS GROQ engine.
Basic Syntax
Every GROQ query follows the same pattern:
*[ filter ]{ projection }
| Part | Purpose |
|---|---|
* | Start with all documents in the dataset |
[ filter ] | Narrow the set with a boolean condition |
{ projection } | Choose which fields to return |
Filters and projections are both optional. * alone returns every published document with all fields.
Running a Query
curl:
curl -X POST https://your-api.cellcms.com/api/v1/data/query/production \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"query": "*[_type == \"post\"]{title, slug}"}'
Client SDK:
import { createClient } from '@cellcms/client'
const client = createClient({
apiUrl: 'https://your-api.cellcms.com',
project: 'my-site',
dataset: 'production',
token: 'cell_your-token',
})
const posts = await client.fetch('*[_type == "post"]{title, slug}')
The API returns:
{
"query": "*[_type == \"post\"]{title, slug}",
"ms": 8,
"result": [
{ "title": "Hello World", "slug": { "current": "hello-world" } }
]
}
The SDK's fetch method unwraps the response and returns result directly.
Filters
Filters go inside square brackets and evaluate to a boolean for every document. Only documents where the condition is true are included.
Comparison Operators
| Operator | Meaning | Example |
|---|---|---|
== | Equal | _type == "post" |
!= | Not equal | status != "archived" |
> | Greater than | price > 100 |
< | Less than | price < 50 |
>= | Greater or equal | rating >= 4 |
<= | Less or equal | stock <= 0 |
Nested fields use dot notation:
*[slug.current == "hello-world"]
Logical Operators
Combine conditions with && (AND), || (OR), and ! (NOT):
// AND — both conditions must be true
*[_type == "post" && publishedAt < now()]
// OR — at least one condition must be true
*[_type == "post" || _type == "page"]
// NOT — negate a condition
*[_type == "post" && !featured]
You can group expressions with parentheses:
*[_type == "product" && (category == "shoes" || category == "boots")]
The in Operator
Check whether a value exists in an array:
// Document field matches one of several values
*[_type == "post" && category in ["tech", "design", "business"]]
The match Operator (Full-Text Search)
Perform full-text search against a field. CellCMS uses PostgreSQL's plainto_tsquery under the hood, so standard text-search rules apply (stemming, stop-word removal).
// Search the title field
*[_type == "post" && title match "serverless deploy"]
// Search the body (Portable Text — use pt::text to extract plain text first)
*[_type == "post" && pt::text(body) match "graphql"]
match is case-insensitive and handles partial word stems automatically.
Projections
Projections determine the shape of each returned document. Without a projection, the full document (all fields) is returned.
Selecting Fields
List the fields you want, separated by commas:
*[_type == "post"]{
title,
slug,
publishedAt
}
Renaming Fields
Use "alias": expression to rename a field in the output:
*[_type == "post"]{
"headline": title,
"url": slug.current,
publishedAt
}
Computed Fields
Any valid GROQ expression can be used as a field value:
*[_type == "product"]{
name,
"priceWithTax": price * 1.21,
"isExpensive": price > 1000,
"status": select(
stock > 10 => "in-stock",
stock > 0 => "low-stock",
"out-of-stock"
)
}
The Spread Operator (...)
Include all existing fields, then add or override specific ones:
*[_type == "post"]{
...,
"excerpt": pt::text(body)
}
Without arguments, ... copies every field from the source document. You can also spread a specific object:
*[_type == "post"]{
...author->{name, bio},
title
}
Ordering
Pipe the result set into order() to sort. Specify one or more fields with asc (ascending, default) or desc (descending):
*[_type == "post"] | order(publishedAt desc)
Sort by multiple fields:
*[_type == "post"] | order(category asc, publishedAt desc)
Ordering is applied before slicing, so you can paginate a sorted list:
*[_type == "post"] | order(publishedAt desc)[0...10]
Full-Text Relevance Scoring
Use | score() with the match operator to sort results by relevance:
*[_type == "post" && title match "react hooks"] | score(title match "react hooks")
Documents that better match the search terms appear first.
Slicing
Slicing limits how many documents are returned and supports pagination.
Range Slice
Return a range of elements (zero-indexed, exclusive end):
// First 10 documents
*[_type == "post"][0...10]
// Documents 11 through 20 (page 2)
*[_type == "post"][10...20]
The syntax is [start...end] where start is inclusive and end is exclusive.
Single Element
Return a single document by index. This changes the result from an array to a single object:
// First document only
*[_type == "post"][0]
// Useful for fetching a unique document
*[_type == "post" && slug.current == "hello-world"][0]
Combining with Order
A typical paginated, sorted query:
*[_type == "post"] | order(publishedAt desc)[0...10]{
title,
slug,
publishedAt
}
Reference Dereferencing
Documents often link to each other through references. A reference field looks like:
{
"author": { "_ref": "author-jane-doe" }
}
Use the -> (dereference) operator to resolve the reference and fetch the linked document.
Basic Dereference
*[_type == "post"]{
title,
author->
}
This replaces the { _ref: "..." } object with the full referenced document.
Dereference with Projection
Fetch only specific fields from the referenced document:
*[_type == "post"]{
title,
author->{name, bio, "avatar": image.asset->url}
}
Accessing a Single Field
*[_type == "post"]{
title,
"authorName": author->name
}
Built-in Functions
CellCMS supports the following functions. Namespaced functions use :: syntax (e.g., math::sum).
General Functions
| Function | Description | Example |
|---|---|---|
count(set) | Count documents or array elements | count(*[_type == "post"]) |
defined(value) | True if the value is not null | defined(publishedAt) |
coalesce(a, b, ...) | Return the first non-null argument | coalesce(subtitle, title) |
references(id) | True if the document contains a reference to id | *[references("author-123")] |
now() | Current date-time as ISO string | publishedAt < now() |
length(str) | Length of a string | length(title) |
lower(str) | Lowercase a string | lower(email) |
upper(str) | Uppercase a string | upper(name) |
round(num) | Round a number to the nearest integer | round(rating) |
string(value) | Cast a value to string | string(count) |
Math Functions
Aggregate functions that operate on arrays of numbers.
| Function | Description | Example |
|---|---|---|
math::sum(array) | Sum of numeric values | math::sum(items[].price) |
math::avg(array) | Average of numeric values | math::avg(reviews[].rating) |
math::min(array) | Minimum value | math::min(variants[].price) |
math::max(array) | Maximum value | math::max(variants[].price) |
Portable Text
| Function | Description | Example |
|---|---|---|
pt::text(blocks) | Extract plain text from Portable Text | pt::text(body) |
Function Examples
Count all posts:
count(*[_type == "post"])
SDK usage:
const total = await client.fetch('count(*[_type == "post"])')
// total = 42
Fetch posts where publishedAt is in the past:
*[_type == "post" && publishedAt < now()] | order(publishedAt desc)
Use coalesce for fallback values:
*[_type == "page"]{
"heading": coalesce(seoTitle, title),
"description": coalesce(seoDescription, "")
}
Filter documents that reference a specific author:
*[_type == "post" && references("author-jane-doe")]{title, slug}
Extract plain text from a Portable Text body:
*[_type == "post"]{
title,
"plainBody": pt::text(body)
}
Parameters
Parameters let you inject values into a query safely without string concatenation. They are prefixed with $ in the query and provided as a separate object.
Syntax
*[_type == "post" && slug.current == $slug][0]
With curl
curl -X POST https://your-api.cellcms.com/api/v1/data/query/production \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"query": "*[_type == \"post\" && slug.current == $slug][0]",
"params": { "slug": "hello-world" }
}'
With the Client SDK
const post = await client.fetch(
'*[_type == "post" && slug.current == $slug][0]',
{ slug: 'hello-world' }
)
Multiple Parameters
const posts = await client.fetch(
'*[_type == "post" && category == $category] | order(publishedAt desc)[0...$limit]',
{ category: 'tech', limit: 10 }
)
Parameters are translated to parameterized PostgreSQL values ($1, $2, etc.), so they are always safe from injection.
Ternary Expressions and Select
Ternary (condition ? a : b)
*[_type == "product"]{
name,
"label": price > 100 ? "premium" : "standard"
}
Select (Multi-Branch)
select() works like a switch statement. Each case has a condition and a result, separated by =>. An optional default value comes last:
*[_type == "order"]{
_id,
"statusLabel": select(
status == "pending" => "Awaiting payment",
status == "shipped" => "On the way",
status == "delivered" => "Delivered",
"Unknown"
)
}
Special Tokens
| Token | Meaning |
|---|---|
* | The set of all documents in the dataset |
@ | The current document in scope |
^ | The parent scope (used in nested filters) |
^ can be chained for deeper nesting: ^.^ refers to the grandparent scope.
Array Operations
Flattening
Use [] to flatten an array, making each element accessible individually:
*[_type == "post"].tags[]
Array Literals
Construct arrays inline:
*[_type == "post"]{
title,
"allTags": ["featured", ...tags]
}
Arithmetic
Standard arithmetic operators are supported inside projections and filters:
| Operator | Meaning | Example |
|---|---|---|
+ | Addition | price + shipping |
- | Subtraction | total - discount |
* | Multiplication | price * quantity |
/ | Division | total / count |
% | Modulo | index % 2 |
*[_type == "product"]{
name,
"totalPrice": price * 1.21,
"savings": originalPrice - price
}
Common Query Recipes
List All Documents of a Type
const posts = await client.fetch('*[_type == "post"]')
Fetch a Single Document by Slug
const post = await client.fetch(
'*[_type == "post" && slug.current == $slug][0]',
{ slug: 'hello-world' }
)
Filtered, Ordered, and Paginated
const page = await client.fetch(
`*[_type == "post" && category == $cat]
| order(publishedAt desc)
[($page * $pageSize)...($page * $pageSize + $pageSize)]
{ title, slug, publishedAt, "author": author->name }`,
{ cat: 'tech', page: 0, pageSize: 10 }
)
Count Documents
const total = await client.fetch('count(*[_type == "post"])')
Reference Join (Post with Author Details)
const posts = await client.fetch(`
*[_type == "post"] | order(publishedAt desc)[0...10]{
title,
slug,
publishedAt,
author->{
name,
bio,
"avatar": image.asset->url
}
}
`)
Full-Text Search
const results = await client.fetch(
'*[_type == "post" && title match $q] | score(title match $q)[0...20]{title, slug}',
{ q: 'serverless' }
)
Find Documents Referencing Another
const relatedPosts = await client.fetch(
'*[_type == "post" && references($authorId)]{title, slug}',
{ authorId: 'author-jane-doe' }
)
Conditional Fields
const products = await client.fetch(`
*[_type == "product"]{
name,
price,
"badge": select(
stock == 0 => "sold-out",
stock < 5 => "almost-gone",
"available"
)
}
`)
All Fields Plus a Computed One
const posts = await client.fetch(`
*[_type == "post"]{
...,
"excerpt": pt::text(body),
"authorName": author->name
}
`)
Differences from Sanity GROQ
CellCMS aims for full compatibility with the GROQ queries used by most Sanity projects. The following notes describe current differences:
| Feature | CellCMS | Sanity |
|---|---|---|
match operator | Uses PostgreSQL full-text search (plainto_tsquery). Supports stemming and stop-word removal for English by default. | Uses Sanity's internal text-matching engine. Behavior may differ on edge cases. |
| Supported functions | 15 functions listed above (count, defined, coalesce, references, now, length, lower, upper, round, string, math::sum, math::avg, math::min, math::max, pt::text). | Sanity supports additional functions such as dateTime(), boost(), geo::, sanity::, and others. |
score() pipe | Supported for ordering by relevance. | Same. |
order() default | Fields default to asc when no direction is specified. | Same. |
| Datasets | Scoped per query endpoint (/data/query/:dataset). Queries always run against a single dataset. | Same. |
| Drafts | Queries only return published (non-draft) documents. Draft documents have a drafts. prefix on their _id and are excluded from query results. | Same default behavior via API; can be overridden with perspective tokens. |
| Cross-dataset queries | Not supported. | Not supported in the standard API. |
If you encounter a GROQ feature from Sanity that does not work in CellCMS, please open an issue.
Related Documentation
- API Reference --
POST /data/query/:datasetendpoint details, error codes, and rate limits - Client SDK --
client.fetch()method, authentication, and TypeScript usage - Getting Started -- Setup, first query, and frontend integration
- Authentication -- API tokens and JWT authentication