Features Apograph CMS on GitHub

Filtering

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

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

Documents 0.5.2 Updated Edit this page Report a problem

On this page

?filter= takes a JSON tree — the same grammar the admin’s query builder emits, so one query language covers both surfaces.

Example language
curl -G 'https://cms.example.com/api/v1/content/article' \
-H 'Authorization: Bearer apograph_…' \
--data-urlencode 'filter={"and":[
    {"field":"featured","op":"eq","value":true},
    {"field":"publishedAt","op":"gte","value":"2026-01-01"}
]}'
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) });
const response = await fetch(
  'https://cms.example.com/api/v1/content/article?' + query,
  { headers: { Authorization: 'Bearer ' + process.env.APOGRAPH_TOKEN } }
);
const { items } = await response.json();
query Featured {
  articles(
      filter: {
          and: [
              { field: "featured", op: eq, value: true }
              { field: "publishedAt", op: gte, value: "2026-01-01" }
          ]
      }
  ) {
      total
      items {
          id
          title
      }
  }
}

The grammar

A node is either a group or a rule.

type Node =
    | { and: Node[] }
    | { or: Node[] }
    | { field: string; op: Operator; value: unknown };

Groups nest, so any boolean expression is expressible:

{
    "and": [
        { "field": "status", "op": "eq", "value": "published" },
        {
            "or": [
                { "field": "category.slug", "op": "eq", "value": "news" },
                { "field": "featured", "op": "eq", "value": true }
            ]
        }
    ]
}

Operators

OperatorMeaningValue
eqEqualsA scalar
neDoes not equalA scalar
likeCase-sensitive pattern matchA string, % as wildcard
ilikeCase-insensitive pattern matchA string, % as wildcard
nilikeDoes not matchA string
inOne ofAn array
ninNone ofAn array
nullIs empty / is not emptytrue for empty, false for not
gtGreater thanA scalar
gteGreater than or equalA scalar
ltLess thanA scalar
lteLess than or equalA scalar
within_lastA rolling window, measured from query time{ "n": 7, "unit": "days" }

There is no between operator on the wire — a range is a gte and an lte in an and group, which is exactly what the admin’s Between control serialises to.

within_last takes n as a positive integer and unit as minutes, hours or days. It is measured from when the query runs, not from when the filter was written, and that distinction is invisible in a URL. The admin resolves its own within-last control into a fixed gte cutoff when it serialises a filter into a link, deliberately: a shared deep link should keep showing the same rows. Send the operator yourself and you get the rolling meaning — which is what a stored, replayed filter such as an alarm rule needs, or “not updated in 90 days” quietly becomes “not updated since the day the rule was written”.

The null operator carries its meaning in the value: {"op":"null","value":true} is “is empty”, and false is “is not empty”.

ilike and literal metacharacters

ilike takes a raw pattern, so % and _ are wildcards. If you are passing user input through to a “contains” filter, wrap it yourself and escape those characters — \%, \_, \\ — or a search for 50% matches far more than you meant.

What is filterable

The type’s own scalar fields, plus id, createdAt, updatedAt, publishedAt, and — on localized types — locale and localeGroupId.

Not filterable: richtext (comparing serialised trees is meaningless), and relation or media fields directly.

status is not filterable on the public API

The public API serves published entries only, so a status rule could only ever be a no-op or match nothing. Use ?status= instead, which needs a full token. The admin API does expose status as a filterable field — which is how the “modified” state is queried there: status eq draft and publishedAt not null.

Relation hops

A rule’s field is a dotted path, and it may cross a relation:

{ "field": "author.name", "op": "ilike", "value": "%ada%" }

Every path is validated against the type’s derived filter schema, segment by segment. The API never builds SQL from a string you send: an unknown field, or a hop into a type the workspace was not granted, is a rejected filter rather than a query.

To discover what a type offers, the admin API exposes GET /api/content-schema/:name/filter-fields, which returns every filterable path with its label, type, and — for a relation’s own id — the target type, so a UI can render a record picker instead of a uuid box.

Limits

LimitValue
Serialised filter length4096 characters
Page size100

The length cap is a first line of defence; the parser has its own node and depth caps that bound the parsed shape regardless.

A malformed tree, an unknown field, an unknown operator, or a value of the wrong shape for its operator is a 400.

Worked examples

Published in a category, newest first

{"and":[
  {"field":"category.slug","op":"eq","value":"engineering"},
  {"field":"publishedAt","op":"null","value":false}
]}

Anything tagged one 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"}
]}

Missing a required editorial field

{"field":"excerpt","op":"null","value":true}
  • Reading entries — the list parameters a filter combines with: sorting, paging, sparse fieldsets and locale.
  • Content alarms — a stored filter tree evaluated on a schedule, and why within_last is rolling there.
  • Errors and status codes — what a rejected filter answers, and what the 400 says.