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
| Operator | SQL | Notes |
|---|---|---|
$eq | = | Implicit when no operator specified |
$neq | != | |
$gt | > | |
$gte | >= | |
$lt | < | |
$lte | <= | |
$contains | ILIKE '%x%' | Case-insensitive |
$startsWith | ILIKE 'x%' | Case-insensitive |
$endsWith | ILIKE '%x' | Case-insensitive |
$in | IN (...) | Bracket array syntax: filter[field][$in][]=a&filter[field][$in][]=b |
$notIn | NOT IN (...) | Bracket array syntax: filter[field][$notIn][]=a&filter[field][$notIn][]=b |
$exists | IS [NOT] NULL | true or false |
$glob | Pattern match | slug 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.
Sorting by a related field
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