Cursor-based pagination
Offset pagination breaks the moment new rows arrive between page requests. Cursor pagination uses a stable opaque token tied to a sort key, so clients can iterate large result sets in constant time without skipping or duplicating rows. This recipe shows the canonical Meridian shape and three patterns you will reuse across every list endpoint.
1.Pick a stable sort key
The cursor must encode a column that is monotonic and unique. Composite keys of (created_at, id) are the safest default. Plain timestamps tie on collision; plain ids do not survive re-sorts. Encode the pair as base64 so the client treats it as opaque.
2.Query with seek predicates
Decode the cursor and translate it into a WHERE clause that seeks past the previous page. Always fetch limit + 1 rows so you know whether to surface a next_cursor in the response.
SELECT id, created_at, payload
FROM events
WHERE (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC
LIMIT 51;
-- decode cursor on server:
const [ts, id] = atob(cursor).split(':');
-- return shape:
{ items, next_cursor: hasMore ? encode(last) : null }3.Surface the next cursor honestly
Return next_cursor: null when the result set is exhausted. Clients should stop polling on null and never synthesize their own cursors. Sign the token with a short HMAC if it ever leaves a trusted boundary so tampered values fail decode rather than scanning the entire table.