Skip to content

Pagination

How to size a list response, and the one resource that hands you the next page.

One resource continues. The rest answer in one response. Knowing which is which is the whole of this page, because a client that waits for a continuation nobody emits stops after one page and believes it is done.

Every list answers the same envelope

{ "items": [], "count": 0, "total": 0 }

count is how many rows this response carries. total is how many exist across every response. When they are equal you have everything — that comparison works whether or not the resource paginates.

The one resource that continues

GET /api/feedback answers a Link header when rows remain.

Link: <https://lateano.com/api/feedback?cursor=bzoxMA>; rel="next"

Follow it. Do not construct it — the cursor is opaque and is meant to stay that way. It encodes an offset today and is not promised to tomorrow, and a client that decodes it is a client the next implementation breaks.

The link is absent on the last page, which is how you stop without counting. Send a cursor this resource did not mint — a truncated paste, a value from an older deployment — and you get the first page rather than a refusal: a 422 on a link somebody followed is a worse answer than starting over.

It reads ?limit= the way the table below says.

limit is not one parameter

This is the part worth reading twice, because the same spelling does not mean the same thing at every address.

Resource ?limit=
GET /api/feedback Read. Default 10, capped at 25, clamped rather than refused.
GET /api/collections Read. No default and no ceiling — omitted, every collection comes back.
GET /api/site-pages Read. No default and no ceiling — omitted, every page comes back.
GET /api/tags Read. No default and no ceiling — omitted, the whole vocabulary comes back.
GET /api/pages Not a search parameter at all — limit is a field of the query grammar, and the grammar refuses what it will not answer.

The first row is the one that catches people. Only /api/feedback reads limit as a page size, with a default and a ceiling. The three rows under it are vocabularies: their sets are closed and small, so leaving limit off asks for all of it rather than for a first page, and naming a number larger than the set is not an error — you get the set.

The query surface refuses rather than clamps

GET /api/pages is the cacheable spelling of a query, and its limit belongs to the grammar rather than to this parameter. The grammar refuses what it cannot answer, with a 422, and that is deliberate — the caps are a property of the question, so an unanswerable question is corrected rather than quietly reduced:

  • ?limit=1000 exceeds the row allowance and is refused.
  • ?limit=abc is not an integer and is refused.
  • A bare /api/pages naming neither a search nor a filter is refused: the whole corpus is not a question.

So do not carry a “clamp it and move on” assumption from the list resources to this one. See Errors for why a 422 means stop sending this rather than resend it differently.

The client worth writing

Follow rel="next" when it is present and stop when it is absent. That client works today against GET /api/feedback, does the right thing against the resources that answer in one response, and keeps working unchanged if any of them ever starts continuing.

---
title: "Pagination"
description: "How to size a list response, and the one resource that hands you the next page."
url: "https://lateano.com/developers/api/pagination"
author: "Colin Lateano"
---

# Pagination

How to size a list response, and the one resource that hands you the next page.

**One resource continues. The rest answer in one response.** Knowing which is
which is the whole of this page, because a client that waits for a continuation
nobody emits stops after one page and believes it is done.

## Every list answers the same envelope

```json
{ "items": [], "count": 0, "total": 0 }
```

`count` is how many rows this response carries. `total` is how many exist across
every response. **When they are equal you have everything** — that comparison
works whether or not the resource paginates.

## The one resource that continues

`GET /api/feedback` answers a `Link` header when rows remain.

```
Link: <https://lateano.com/api/feedback?cursor=bzoxMA>; rel="next"
```

Follow it. Do not construct it — **the cursor is opaque and is meant to stay
that way.** It encodes an offset today and is not promised to tomorrow, and a
client that decodes it is a client the next implementation breaks.

**The link is absent on the last page**, which is how you stop without counting.
Send a cursor this resource did not mint — a truncated paste, a value from an
older deployment — and you get the first page rather than a refusal: a `422` on a
link somebody followed is a worse answer than starting over.

It reads `?limit=` the way the table below says.

## `limit` is not one parameter

This is the part worth reading twice, because the same spelling does not mean the
same thing at every address.

| Resource               | `?limit=`                                                                                                                     |
| :--------------------- | :---------------------------------------------------------------------------------------------------------------------------- |
| `GET /api/feedback`    | Read. Default `10`, capped at `25`, clamped rather than refused.                                                              |
| `GET /api/collections` | Read. No default and no ceiling — omitted, every collection comes back.                                                       |
| `GET /api/site-pages`  | Read. No default and no ceiling — omitted, every page comes back.                                                             |
| `GET /api/tags`        | Read. No default and no ceiling — omitted, the whole vocabulary comes back.                                                   |
| `GET /api/pages`       | **Not a search parameter at all** — `limit` is a field of the query grammar, and the grammar refuses what it will not answer. |

**The first row is the one that catches people.** Only `/api/feedback` reads
`limit` as a page size, with a default and a ceiling. The three rows under it
are vocabularies: their sets are closed and small, so leaving `limit` off asks
for all of it rather than for a first page, and naming a number larger than the
set is not an error — you get the set.

## The query surface refuses rather than clamps

`GET /api/pages` is the cacheable spelling of a query, and its `limit` belongs to
the grammar rather than to this parameter. **The grammar refuses what it cannot
answer, with a `422`**, and that is deliberate — the caps are a property of the
question, so an unanswerable question is corrected rather than quietly reduced:

- `?limit=1000` exceeds the row allowance and is refused.
- `?limit=abc` is not an integer and is refused.
- A bare `/api/pages` naming neither a search nor a filter is refused: the whole
  corpus is not a question.

So do not carry a "clamp it and move on" assumption from the list resources to
this one. See [Errors](/developers/api/errors) for why a `422` means _stop
sending this_ rather than _resend it differently_.

## The client worth writing

Follow `rel="next"` when it is present and stop when it is absent. That client
works today against `GET /api/feedback`, does the right thing against the
resources that answer in one response, and keeps working unchanged if any of them
ever starts continuing.