---
title: Pagination and filtering
description: Every listing pages, nothing returns everything, and filtering happens in the database.
---

## Nothing returns everything

Every listing on `/v1` is paginated server-side, and there is no parameter that
asks for all of something.

This is deliberate. An endpoint that will return ten thousand rows if asked is
an endpoint that eventually does, at the worst possible moment, on somebody
else's traffic. Bounding it here means the ceiling is ours to reason about
rather than every integrator's to remember.

**`limit`** is 1–100, default 20. A value outside that range is **refused**
(`invalid_limit`), not clamped: a caller building a paging loop must never
silently receive a different page size than it asked for.

## The one loop that works everywhere

Read **both** `meta.next_offset` and `meta.next_cursor`. Exactly one is set on
a page that has a successor, and **both are null on the last page**.

```js
let params = { limit: 50 };
const all = [];

for (;;) {
  const res = await fetch(url + '?' + new URLSearchParams(params), { headers });
  const { data, meta } = await res.json();
  all.push(...data);

  if (meta.next_offset != null) params.offset = meta.next_offset;
  else if (meta.next_cursor != null) params.cursor = meta.next_cursor;
  else break;
}
```

Do not compute the next offset yourself, and do not loop on `has_more` alone.

## Why there are two styles

**Cursor.** `/v1/blog/articles`, and `/v1/content` collections in their
default order. A cursor encodes a position in that order, so rows inserted
while you are paging cannot make you skip or repeat one.

**Offset.** Everything else: `/v1/kb/*`, `/v1/authors`,
`/v1/blog/categories`, and `/v1/content` **whenever `sort` is used**.

That last case is the one that surprises people. A cursor is a position in the
*default* order, so asking for a different order invalidates it:
`next_cursor` goes null and `next_offset` fills in. The response tells you
which one it answered with, so read it rather than assuming.

Treat a cursor as opaque. It is a base64 position, not a key you can construct.

## Filtering

Filtering happens in the database, on indexed columns, and is validated against
the declared schema.

### Custom content: `where[…]`

```
?where[role]=engineer
?where[price][gte]=100
?where[tag][in]=a,b,c
?where[archived_at][is_null]=true
```

`where[field]=value` is equality; `where[field][op]=value` for anything else.
Conditions are **ANDed**. There is no OR and no nesting, because a query that
needs them is describing a report, not a page, and should run against your own
copy of the data.

**Operators:** `eq` `ne` `gt` `gte` `lt` `lte` `contains` `starts_with` `in`
`is_null`.

Which ones apply depends on the field type. See the table in
[Content types](/concepts/content-model#the-field-types). An operator the type
does not support is refused, not silently ignored.

`in` takes at most 50 values. So does `?ids=`.

> **INFO: A wrong name teaches you the right one**
>
> `?where[rôle]=engineer` answers `400 unknown_filter_field` and names every
> field the type declares. `?sort=nope` names the sortable ones. You never have
> to guess twice.

### Built-in modules: named filters

The modules have a fixed schema, so they expose named filters instead:

- **Blog:** `category`, `author`, `tag`, `q`
- **Knowledge base:** `collection`, `section`, `q`

All of them take **slugs**, not ids, because a page URL already contains the
slug and looking up an id first would be a request per page load to learn
something you already know.

`category` and `author` match an article carrying that slug **anywhere** in its
list, so a co-authored post appears on both authors' pages.

### Search

`q` is a case-insensitive **substring** match on titles and summaries or
excerpts, backed by a trigram index so it stays fast on a large corpus.

It is not ranked full-text search and it does not stem: "running" will not
match "run". It combines with the other filters, which is how a category or
collection page offers search *within itself* without a second endpoint.

## Empty is not the same as unknown

- A filter that matches nothing → `200` and an empty array. Your query was
  valid; there is nothing there.
- A name that does not exist, such as a category, section, collection or field →
  `4xx` naming the real ones.

So an empty list is never a typo, and a typo is never an empty list. This is
worth relying on: it is the difference between "we have no posts in
Engineering" and "you spelled Engineering wrong", which are otherwise
indistinguishable.

## Sorting

`/v1/content` takes `?sort=`: a field key, or `created_at`, `published_at`,
`updated_at`. Prefix with `-` for descending.

The **knowledge base has no `sort`, deliberately**. Every listing comes back in
the order somebody dragged it into. A help centre in date order is not a help
centre.