Pagination¶
List endpoints support two pagination modes out of the box:
- Offset mode — page numbers plus a fixed page size. Easy to reason about, good for small-to-medium datasets.
- Cursor mode — signed opaque tokens with O(1) seeks. Required for large collections, infinite scroll, or stable iteration while rows are being inserted.
Mode selection is automatic: send page/per_page and you get offset pagination; send cursor/limit and you get cursor pagination. You can mix both on the same endpoint without changing the server.
Offset mode¶
Default mode. Send page and per_page:
curl "https://api.example.com/products?page=1&per_page=25"
curl "https://api.example.com/products?page=2&per_page=25&sort=-created_at"
Response:
And these response headers:
| Header | Example | Purpose |
|---|---|---|
X-Total-Count |
417 |
Total rows matching the filter. |
X-Page |
2 |
Current page number. |
X-Per-Page |
25 |
Rows per page. |
X-Total-Pages |
17 |
Total pages at the current page size. |
Defaults. per_page defaults to 25. Each Resource declares a max_page_size (typically 100), and page sizes above that ceiling are rejected with 422. page defaults to 1.
Cost. Offset pagination uses skip(offset).limit(size) under the hood. The cost grows linearly with the offset because MongoDB still walks every skipped document. For anything beyond a few hundred pages, switch to cursor mode.
Cursor mode¶
Pass cursor (or limit without page) to enable cursor pagination:
# First page
curl "https://api.example.com/products?limit=25&sort=-created_at"
# Response carries a cursor for the next page
{
"items": [ /* 25 documents */ ],
"cursors": {
"next": "eyJfaWQiOnsi...",
"prev": null,
"has_next": true,
"has_prev": false
}
}
# Next page
curl "https://api.example.com/products?cursor=eyJfaWQiOnsi...&limit=25"
direction controls which way you move through the dataset. It accepts next (default) or prev:
How the cursor is built¶
Cursor tokens are signed JSON payloads. Internally, the server:
- Reads the last item of the returned page.
- Extracts the values of the sort fields and
_id(always added as a tiebreaker). - Serializes them to JSON.
- Signs with
HMAC-SHA256usingCURSOR_HMAC_SECRETand appends the signature. - Base64url-encodes the whole thing.
When you pass the cursor back, the server verifies the signature, restores the field values (including ObjectIds), and builds a MongoDB range query. For a sort like [("created_at", -1), ("_id", -1)], the next-page query looks like:
{
"$or": [
{"created_at": {"$lt": "<cursor_created_at>"}},
{"created_at": "<cursor_created_at>", "_id": {"$lt": "<cursor_id>"}}
]
}
No skip() is involved — MongoDB seeks straight to the right spot using the index.
Why sign the cursors¶
A cursor contains raw field values from your data. If an attacker could tamper with it, they could steer the query at arbitrary rows. The HMAC signature prevents forgery: any modification to the payload invalidates the signature, and the server returns 400 — Invalid cursor — signature mismatch.
Set CURSOR_HMAC_SECRET in production
The default value is change-me-in-production. Replace it with a long random string (at least 32 bytes) before deploying. If you leak the secret, rotate it — all outstanding cursors become invalid, but that is recoverable.
Edge cases¶
- New data during iteration. Rows created after you received a cursor may or may not appear, depending on the sort order. For
-created_at, newer rows stay at the top and are missed bynexttraversal — that is the usual trade-off. - Mutated sort fields. If a row's sort field value changes between pages, it may be skipped or repeated. Use immutable sort fields (like
created_at) for stable iteration. - Backward pagination.
direction=prevreverses the sort, fetches in reversed order, then flips the result back on the server so the caller always sees items in forward order.
Automatic mode detection¶
The server decides which mode to use based on the query parameters:
| Parameters sent | Mode |
|---|---|
cursor=... (any combination) |
Cursor |
limit=... without page |
Cursor |
page=... and/or per_page=... |
Offset |
| Neither set | Offset (page=1, per_page=25) |
This means a client can start in offset mode for a table view and switch to cursor mode for a scroll view without any server changes.
Field projection and populate¶
Both modes honour fields (projection) and populate (eager loading of related documents) in the same way:
GET /orders?limit=50&fields=id,reference,total,customer_name
GET /orders?limit=50&populate=customer_id,items
Example: paginating the whole collection¶
BASE=https://api.example.com/events
CURSOR=""
while : ; do
URL="$BASE?limit=500&sort=-created_at"
[ -n "$CURSOR" ] && URL="$URL&cursor=$CURSOR"
response=$(curl -s "$URL")
echo "$response" | jq '.items[] | .id'
has_next=$(echo "$response" | jq -r '.cursors.has_next')
[ "$has_next" = "true" ] || break
CURSOR=$(echo "$response" | jq -r '.cursors.next')
done
Source¶
craft_easy/core/crud/pagination.py—detect_mode,paginate_offset,paginate_cursor, and the HMAC cursor encoder.craft_easy/settings.py—CURSOR_HMAC_SECRETsetting.craft_easy/core/crud/resource.py— list endpoint that wires the two pagination modes together.