Querying

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 }
PartPurpose
*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

OperatorMeaningExample
==Equal_type == "post"
!=Not equalstatus != "archived"
>Greater thanprice > 100
<Less thanprice < 50
>=Greater or equalrating >= 4
<=Less or equalstock <= 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"]]

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

FunctionDescriptionExample
count(set)Count documents or array elementscount(*[_type == "post"])
defined(value)True if the value is not nulldefined(publishedAt)
coalesce(a, b, ...)Return the first non-null argumentcoalesce(subtitle, title)
references(id)True if the document contains a reference to id*[references("author-123")]
now()Current date-time as ISO stringpublishedAt < now()
length(str)Length of a stringlength(title)
lower(str)Lowercase a stringlower(email)
upper(str)Uppercase a stringupper(name)
round(num)Round a number to the nearest integerround(rating)
string(value)Cast a value to stringstring(count)

Math Functions

Aggregate functions that operate on arrays of numbers.

FunctionDescriptionExample
math::sum(array)Sum of numeric valuesmath::sum(items[].price)
math::avg(array)Average of numeric valuesmath::avg(reviews[].rating)
math::min(array)Minimum valuemath::min(variants[].price)
math::max(array)Maximum valuemath::max(variants[].price)

Portable Text

FunctionDescriptionExample
pt::text(blocks)Extract plain text from Portable Textpt::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

TokenMeaning
*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:

OperatorMeaningExample
+Additionprice + shipping
-Subtractiontotal - discount
*Multiplicationprice * quantity
/Divisiontotal / count
%Moduloindex % 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
    }
  }
`)
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:

FeatureCellCMSSanity
match operatorUses 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 functions15 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() pipeSupported for ordering by relevance.Same.
order() defaultFields default to asc when no direction is specified.Same.
DatasetsScoped per query endpoint (/data/query/:dataset). Queries always run against a single dataset.Same.
DraftsQueries 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 queriesNot 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.


  • API Reference -- POST /data/query/:dataset endpoint 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