Filtering & Pagination

The query format every generated GET accepts - filters, sorting, projection, paging - and the helpers that let you reuse it anywhere.

Every GET endpoint generated by createCRUD accepts a rich query for filtering, sorting, projection and paging. The /count endpoint accepts the filtering part of it.

You do not have to build any of this yourself, and the generated SDK and Thunder UI both speak it already.

Query Parameters

Query strings are parsed with qs, so nested values use bracket notation:

GET /users/api?filters[name][$eq]=john&limit=50&sort[createdAt]=-1
ParamDefaultNotes
filters-The client filter object (see below)
subFilters-A second, independently applied filter layer
offset0$skip
limit2000$limit; minimum 1, maximum 2000
sort{ _id: -1 }MongoDB sort object; values between -1 and 1
project-MongoDB projection; a value may also be { $slice: n } or { $slice: [n, n] }

/count accepts only filters and subFilters.

limit is capped at 2000. If you need more, page through with offset rather than trying to raise it.

The Filter Format

A filter value is either a plain string or a tagged object. The tag is how a client sends a typed value over a query string, which is otherwise all text:

{
  "name":      { "$regex": { "type": "regex", "value": "^jo", "options": { "regexFlags": "i" } } },
  "user":      { "$eq": { "type": "objectId", "value": "68f0a1..." } },
  "age":       { "$gte": { "type": "number", "value": "18" } },
  "verified":  { "$eq": { "type": "boolean", "value": "true" } },   // "true" / "1" are truthy
  "deletedAt": { "$exists": { "type": "boolean", "value": "false" } },
  "parent":    { "$eq": { "type": "null", "value": "" } },
  "role":      { "$in": ["admin", "owner"] },
  "banned":    { "$not": { "$eq": "true" } },

  // A date takes an ISO string, or `nldate:` + a natural-language expression
  "createdAt": { "$gte": { "type": "date", "value": "nldate:7 days ago" } },
  "expiresAt": { "$lte": { "type": "date", "value": "nldate:next friday" } }
}

Value types

string, number, boolean, objectId, date, regex, null.

Operators

$exists, $eq, $ne, $gt, $gte, $lt, $lte, $regex, $in, $nin, $all, $mod, and $not wrapping any expression.

Combine whole filter objects with { "$and": [...] } and { "$or": [...] }.

$mod takes a raw [divisor, remainder] tuple, not a tagged value.

Dates in plain English

nldate: accepts a natural-language expression, which is far easier to send from a UI than a computed timestamp:

?filters[createdAt][$gte][type]=date&filters[createdAt][$gte][value]=nldate:7 days ago

A $regex pattern longer than 200 characters is rejected with a 400. The pattern comes from the client and is executed by mongod, so the limit is a safety measure - keep patterns short.

Reusing the Filter Layer

The filter layer is exported separately, so the same client filter format can be used outside a CRUD endpoint. Reach for these instead of inventing a second filter format.

All are exported from @/core/utils/createCRUD.ts:

HelperUse
normalizeFilters(filters)Convert a client filter into a MongoDB filter, resolving objectId, date, nldate:, regex, …
normalizeFilterExpression(value)The same, for a single expression
testFilters(filters, data)Evaluate a client filter against a plain object in memory, with no database round trip
paginatedAggregation(query)Build the $match / $sort / $skip / $limit / $project stages for a parsed pagination query

Schemas are exported too, for validating a query yourself: $pagination, $filter, $basicFilter, $multiFilter, $expression, $clientValue (each also available as …Schema).

testFilters for access rules

testFilters is the interesting one: because it runs in memory, you can use a client-supplied filter as a policy without ever hitting the database.

import { testFilters } from "@/core/utils/createCRUD.ts";

// Does this record satisfy the rule attached to the user's role?
if (!testFilters(rule.conditions, record)) {
  throw Response.forbidden("Not allowed for this record");
}

paginatedAggregation in a custom route

When you hand-roll an aggregation but still want the standard paging behaviour:

import { paginatedAggregation } from "@/core/utils/createCRUD.ts";

const stages = paginatedAggregation(query);
const results = await orderModel
  .aggregate([{ $match: { tenant } }, ...stages])
  .toArray();


On this page