Skip to content

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:

{
  "items": [ /* documents */ ],
  "total": 417,
  "page": 2,
  "per_page": 25
}

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:

curl "https://api.example.com/products?cursor=eyJfaWQiOnsi...&limit=25&direction=prev"

How the cursor is built

Cursor tokens are signed JSON payloads. Internally, the server:

  1. Reads the last item of the returned page.
  2. Extracts the values of the sort fields and _id (always added as a tiebreaker).
  3. Serializes them to JSON.
  4. Signs with HMAC-SHA256 using CURSOR_HMAC_SECRET and appends the signature.
  5. 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 by next traversal — 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=prev reverses 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.pydetect_mode, paginate_offset, paginate_cursor, and the HMAC cursor encoder.
  • craft_easy/settings.pyCURSOR_HMAC_SECRET setting.
  • craft_easy/core/crud/resource.py — list endpoint that wires the two pagination modes together.