Beta . Ortha CMS is in public beta. Every plan is free until it ends.
Features Ortha CMS on GitHub

Filtering

Content plugin @orthacms/content-server@orthacms/content-admin

A JSON tree of and/or groups and rules, validated against the type's own schema.

Documents 0.13.1 Updated Edit this page Report a problem

On this page

?filter= takes a JSON tree — the same grammar the admin’s query builder emits. GraphQL’s filter: argument takes the same tree.

Example language
curl -G 'https://cms.example.com/api/v1/content/article' \
-H 'Authorization: Bearer orthacms_…' \
--data-urlencode 'filter={"and":[
    {"field":"featured","op":"eq","value":true},
    {"field":"publishedAt","op":"gte","value":"2026-01-01"}
]}' \
--data-urlencode 'sort=-publishedAt'
const filter = {
  and: [
      { field: 'featured', op: 'eq', value: true },
      { field: 'publishedAt', op: 'gte', value: '2026-01-01' }
  ]
};
const query = new URLSearchParams({
  filter: JSON.stringify(filter),
  sort: '-publishedAt'
});
const response = await fetch(
  'https://cms.example.com/api/v1/content/article?' + query,
  { headers: { Authorization: 'Bearer ' + process.env.ORTHACMS_TOKEN } }
);
const { items, total } = await response.json();
# variables: { "filter": { "and": [
#   { "field": "featured", "op": "eq", "value": true },
#   { "field": "publishedAt", "op": "gte", "value": "2026-01-01" } ] } }
query Featured($filter: JSON) {
  articles(filter: $filter, sort: "-publishedAt") {
      total
      items {
          id
          title
      }
  }
}
  • Always URL-encode the JSON on REST (--data-urlencode, URLSearchParams).
  • In GraphQL, pass the tree as a JSON variable. Written inline, op is still a string: op: "eq".
  • Since 0.7.0, a published read filters the published version of a modified entry, never its unpublished edit.

The grammar

A node is a group or a rule. Groups nest.

type Node =
    | { and: Node[] }
    | { or: Node[] }
    | { field: string; op: Operator; value: unknown };
{
    "and": [
        { "field": "publishedAt", "op": "gte", "value": "2026-01-01" },
        {
            "or": [
                { "field": "category.slug", "op": "eq", "value": "news" },
                { "field": "featured", "op": "eq", "value": true }
            ]
        }
    ]
}

Operators

OperatorMeaningValue
eqEqualsA scalar
neDoes not equalA scalar
likeCase-sensitive patternA string, % as wildcard
ilikeCase-insensitive patternA string, % as wildcard
nilikeDoes not match, case-insensitiveA string
inOne ofAn array (max 100)
ninNone ofAn array (max 100)
nullIs empty / is not emptytrue / false
gt gte lt lteComparisonsA scalar
within_lastRolling window from query time{ "n": 7, "unit": "days" }
  • Which fields take which operators: text and select take all except within_last. Numbers, booleans and ids take comparisons, in, nin, null. within_last is for date and date-time only.
  • No between. Use gte + lte in an and.
  • Negatives include empty. ne, nin and nilike also match rows where the field is empty.
  • within_last takes n (positive integer) and unit (minutes, hours, days). It is measured when the query runs — the rolling meaning a stored alarm rule needs. (The admin’s deep links resolve it to a fixed gte cutoff instead.)

Escaping user input in ilike

% and _ are wildcards in like/ilike. Escape them before wrapping user input in a “contains” pattern:

const escapeLike = (text) => text.replace(/[\\%_]/g, (c) => '\\' + c);

const rule = { field: 'title', op: 'ilike', value: '%' + escapeLike(input) + '%' };

What is filterable

  • The type’s own scalar fields: text, select, number, money, boolean, date, date-time.
  • id, createdAt, updatedAt, publishedAt.
  • On localized types: locale, localeGroupId.

Not filterable: richtext, json, multiselect, and relation or media fields directly (hop into them instead).

status is not filterable on the public API

Use ?status= (needs a full token). The admin API does expose status; there the Modified state is status eq draft and publishedAt not null.

Relation hops

A field may be a dotted path across relations:

{ "field": "author.name", "op": "ilike", "value": "%ada%" }
  • At most two hops: author.company.name is allowed, author.company.country.name is not.
  • Every segment is validated against the schema. An unknown field or a hop into an unreadable type is a 400.
  • A hop is workspace-scoped and skips soft-deleted targets, but does not check the target’s publish state. Add author.status when it must be published.

The admin API lists every filterable path for a type: GET /api/content-schema/:name/filter-fields.

Limits

LimitValue
Serialised filter length4096 characters
Groups and rules in one tree50
and / or nesting5 levels
Values in one in / nin100
Relation hops in one path2
Page size100

A malformed tree, unknown field or operator, or a value of the wrong shape is a 400:

{ "message": "…", "error": "Bad Request", "statusCode": 400 }

Recipes

In a published category, newest first (sort=-publishedAt is a separate parameter):

{"and":[
  {"field":"category.slug","op":"eq","value":"engineering"},
  {"field":"category.status","op":"eq","value":"published"}
]}

Tagged with any of several ids

{"field":"tags.id","op":"in","value":["9c4b…","1f9a…"]}

A date range

{"and":[
  {"field":"publishedAt","op":"gte","value":"2026-01-01"},
  {"field":"publishedAt","op":"lte","value":"2026-03-31"}
]}

Updated in the last 24 hours (an incremental sync):

{"field":"updatedAt","op":"within_last","value":{"n":24,"unit":"hours"}}

Missing an excerpt

{"field":"excerpt","op":"null","value":true}