Querying

Filter, sort, paginate, and select fields via query parameters.

All query parameters are parsed with qs and support nested bracket syntax. The query engine translates them into SQL, aware of each field’s storage type (column, JSONB, or relation).

Filtering

Basic equality


          GET /api/posts?filter[status]=published
        

Operators

Use operator keys for comparisons:


          GET /api/posts?filter[publishedAt][$gte]=2025-01-01
GET /api/posts?filter[title][$contains]=design
GET /api/posts?filter[priority][$in][]=high&filter[priority][$in][]=medium
        

Supported operators

OperatorSQLNotes
$eq=Implicit when no operator specified
$neq!=
$gt>
$gte>=
$lt<
$lte<=
$containsILIKE '%x%'Case-insensitive
$startsWithILIKE 'x%'Case-insensitive
$endsWithILIKE '%x'Case-insensitive
$inIN (...)Bracket array syntax: filter[field][$in][]=a&filter[field][$in][]=b
$notInNOT IN (...)Bracket array syntax: filter[field][$notIn][]=a&filter[field][$notIn][]=b
$existsIS [NOT] NULLtrue or false
$globPattern matchslug and text fields only

OR conditions


          GET /api/posts?filter[$or][0][status]=draft&filter[$or][1][status]=review
        

Relation traversal

Filter by fields on related entities using dot notation:


          GET /api/posts?filter[author.name]=Pedro
        

This generates a subquery that joins to the authors table. The subquery is scoped by the requester’s read permission on the target entity: you can only match related rows you’re allowed to read, and filtering through a relation the role can’t read at all is rejected.

JSONB path filtering

For fields stored as JSONB, dot notation extracts nested values:


          GET /api/posts?filter[body.title][$contains]=hello
        

Translates to: body->>'title' ILIKE '%hello%'

Top-level JSONB fields (without dot notation) only support $exists. Use dot notation for path-specific queries (e.g., filter[body.title][$contains]=hello).

Glob patterns

The $glob operator is supported on slug and text fields. Use * as a wildcard in the pattern.

Slug and path fields apply path-depth semantics:


          # Direct children of /blog/2025/ (no deeper nesting)
GET /api/pages?filter[slug][$glob]=/blog/2025/*

# All descendants under /blog/ (any depth)
GET /api/pages?filter[slug][$glob]=/blog/**/*
        

          -- /blog/2025/* → direct children only
WHERE slug ILIKE '/blog/2025/%' AND slug NOT LIKE '/blog/2025/%/%'

-- /blog/**/* → all descendants
WHERE slug ILIKE '/blog/%'
        

Text fields treat * as a simple wildcard (maps to % in ILIKE):


          # Redirect lookup by prefix pattern
GET /api/redirects?filter[from][$glob]=/old-shop/*

# Wildcards anywhere in the pattern
GET /api/redirects?filter[from][$glob]=*/sale/*
        

          -- /old-shop/* → any value starting with /old-shop/
WHERE "from" ILIKE '/old-shop/%'
        

When no wildcard is present, $glob is equivalent to $eq. Reach for $contains, $startsWith, or $endsWith when you don’t need wildcard placement flexibility — they are more explicit about intent.

Sorting

Use the sort parameter with comma-separated fields. Prefix with - for descending:


          GET /api/posts?sort=-publishedAt,title
        

Maps to: ORDER BY published_at DESC, title ASC

Default sort is createdAt DESC.

From the client, sort is typed off the entity’s orderable columns — a single token autocompletes (ascending, or - for descending), and an array is a multi-key sort, comma-joined onto the wire. A relation path like author.name (see below) is still accepted as a plain string.


          await cms.list("posts", { sort: ["-publishedAt", "title"] });
        

JSONB fields cannot be used for sorting.

Sort by a field on a single-target relation with dot notation:


          GET /api/posts?sort=author.name
        

The engine emits a correlated subquery against the related table. Like relation filters, it is scoped by the requester’s read permission on the target: related rows the role can’t read contribute no value (they sort as NULL), and sorting through a relation whose target the role can’t read at all is rejected. Many-relations can’t be sorted — there’s no canonical aggregation when a row has multiple targets. See Sorting across polymorphic targets for the polymorphic case.

Field selection

Control which fields appear in the response:


          GET /api/posts?fields=title,slug,author
        

The id field is always included. Omit fields to return all fields.

Pagination


          GET /api/posts?limit=10&offset=20
        
  • limit: 1–100 (default: 20)
  • offset: 0+ (default: 0)

List responses include pagination metadata:


          {
  "data": [...],
  "meta": {
    "total": 42,
    "limit": 10,
    "offset": 20
  }
}
        

Relation resolution

See the Relations guide for details on the resolve parameter.


          GET /api/posts?resolve[author]=name,bio&resolve[tags]=*
GET /api/posts?resolve[author.avatar]=url                  # chained: drills into author.avatar
GET /api/pages?resolve[blocks.author_feature.author]=name  # nested in a blocks array, keyed by variant
        

From the client, pass an inline resolve tree typed against your config — relations resolve in place and the response type follows the selection:


          const { data } = await cms.find("page", {
  resolve: { blocks: { hero: { cta: { target: { path: true } } } } },
  filter: { path: "/" },
});
        

See Resolving relations from the client.

Combining parameters

All parameters compose freely:


          GET /api/posts?filter[status]=published&sort=-publishedAt&limit=5&fields=title,slug&resolve[author]=name
        

Previous

Typing & Schemas

Next

Permissions