# Pagination

> Hatched ships two pagination envelopes — cursor (canonical for new endpoints) and offset (legacy). This page documents both shapes, the SDK helpers that walk them, and how to add cursor pagination to a server-side route.

Source: https://docs.hatched.live/docs/concepts/pagination

import { Callout } from 'fumadocs-ui/components/callout';

Every Hatched list endpoint returns one of two envelopes. New endpoints
use **cursor pagination**; older endpoints still return the offset
shape. The SDK has helpers for both — application code doesn't need to
care which shape it's reading.

## Cursor pagination (canonical)

The canonical envelope — emitted by the server-side `cursorPaginate`
helper, the shape new endpoints should adopt:

```json
{
  "data": [ /* page rows */ ],
  "pagination": {
    "nextCursor": "eyJrIjoiMjAyNi0wNS0yNVQxODoxMjozNFoiLCJpZCI6IjkyZS4uLn0",
    "hasMore": true,
    "limit": 50
  }
}
```

The cursor is opaque — treat it as a JWT-ish blob. It encodes the sort
key + tie-breaker id of the last row in the current page, so the next
call returns the rows immediately after it.

<Callout type="warn">
The cursor resources shipped today do **not** yet return this envelope.
Each exposes the cursor at the top level on its own data key:
`notifications.list` → `{ notifications, nextCursor, unreadCount, pausedUntil }`,
`feed.teamEvents.list` → `{ events, next_cursor }` (snake_case),
`webhooks.deliveries` → `{ data, nextCursor }`. The `{ data, pagination }`
envelope is what `cursorPaginate` produces for new server routes; until a
resource is wired to it, adapt the resource's real shape into the
`paginateCursor` fetcher (see [Walking pages from the SDK](#walking-pages-from-the-sdk)).
</Callout>

**Why cursor.** Stable under concurrent writes (no missed/duplicated
rows when new data lands between page calls), supports unbounded streams
(no upper bound from `total` reaching memory limits), and aligns with
keyset indexing so latency stays flat as data grows.

### Request

Pass `cursor` and `limit` query params:

```
GET /api/v1/widget/notifications?cursor=eyJrIjoiMjAy...&limit=100
```

- `cursor` (optional) — omit on the first request; pass the previous
  response's `nextCursor` (the canonical envelope nests it under
  `pagination.nextCursor`) on subsequent requests.
- `limit` (optional) — page size, clamped server-side. The `cursorPaginate`
  helper defaults to 50 and clamps to `[1, 200]`.

### Response invariants

- `data.length` is always `≤ pagination.limit`.
- `pagination.hasMore === (pagination.nextCursor !== null)`.
- When `nextCursor === null`, the cursor chain is exhausted.

## Offset pagination (legacy)

The envelope:

```json
{
  "data": [ /* page rows */ ],
  "meta": { "total": 1284, "page": 1, "limit": 50 }
}
```

**Why we still ship it.** Several endpoints already exposed this shape
publicly. The contract is preserved; new endpoints just don't add to
the surface.

### Request

Pass `page` and `limit`:

```
GET /api/v1/buddies?page=2&limit=100
```

- `page` (1-indexed, default 1).
- `limit` (default 20, max 100). A `limit` above 100 is rejected with
  `422 validation_failed`.

### Response invariants

- `meta.total` is the authoritative termination signal — the SDK paginator
  stops when `(page-1) * limit + data.length >= total`. Don't infer "this is
  the last page" from `data.length < limit`: filtered endpoints (status
  filter, soft deletes, audience scoping) routinely return a short page in
  the middle of the stream.
- `data.length` may be `0` on the final page (when `total` is an exact
  multiple of `limit`).

## Walking pages from the SDK

The SDK exposes two pairs of helpers. Each returns an
`AsyncIterableIterator<T>` so you can `for await … break` to stop early
without fetching the next page.

### Cursor

`paginateCursor` / `collectCursor` expect the canonical
`{ data, pagination: { nextCursor } }` envelope. Today's cursor resources
return the cursor at the top level on their own data key, so map the
resource's real shape into that envelope inside the fetcher:

```ts
import { paginateCursor, collectCursor } from '@hatched/sdk-js';

for await (const note of paginateCursor((cursor) =>
  hatched.notifications.list({ cursor, limit: 100 }).then((page) => ({
    data: page.notifications,
    pagination: { nextCursor: page.nextCursor, hasMore: page.nextCursor !== null, limit: 100 },
  })),
)) {
  if (note.id === target) break;
}

// or buffer everything
const all = await collectCursor((cursor) =>
  hatched.notifications.list({ cursor, limit: 100 }).then((page) => ({
    data: page.notifications,
    pagination: { nextCursor: page.nextCursor, hasMore: page.nextCursor !== null, limit: 100 },
  })),
);
```

### Offset

```ts
import { paginate, collect } from '@hatched/sdk-js';

for await (const buddy of paginate(
  (page) => hatched.buddies.list({ page, limit: 100, status: 'active' }),
)) {
  console.log(buddy.id);
}

const active = await collect(
  (page) => hatched.buddies.list({ page, limit: 100, status: 'active' }),
);
```

Both pairs accept a `maxPages` runaway guard and an `AbortSignal`:

```ts
const ac = new AbortController();
setTimeout(() => ac.abort(), 5_000);
const bounded = await collectCursor(
  (cursor) =>
    hatched.notifications.list({ cursor, signal: ac.signal }).then((page) => ({
      data: page.notifications,
      pagination: { nextCursor: page.nextCursor, hasMore: page.nextCursor !== null, limit: 50 },
    })),
  { signal: ac.signal, maxPages: 20 },
);
```

## Server-side: adding cursor pagination to a new endpoint

For new endpoints in `apps/api`, use the shared cursor helper instead
of writing keyset logic by hand:

```ts
import { cursorPaginate } from '@/common/pagination/cursor';

@Get('items')
async list(@Query() query: CursorListItemsDto) {
  const qb = this.itemsRepo
    .createQueryBuilder('item')
    .where('item.customer_id = :customerId', { customerId });

  return cursorPaginate({
    qb,
    sortColumn: 'item.created_at',
    tieBreakerColumn: 'item.id',
    direction: 'DESC',
    limit: query.limit,
    cursor: query.cursor,
  });
}
```

The helper produces the canonical envelope, applies a keyset filter so
the SQL plan stays on an index, and uses the row's `createdAt` + `id`
as the cursor tuple by default. Override `toCursor` for endpoints sorted
by a different column.

### Choosing a sort key

The sort column must be **monotonic** within the result set — usually
`created_at`. The tie-breaker is always a unique id (UUID). With those
two together, two rows can never tie, so the keyset predicate is exact
and the cursor never drops or duplicates rows.

## When to migrate an offset endpoint to cursor

Existing offset endpoints stay as-is. Migrate when one of these is true:

- The list grows unbounded (events, operations, ledger entries) and
  `OFFSET N LIMIT M` starts to scan a problematic number of pages.
- Consumers report missed/duplicated rows under concurrent writes.
- You need to expose a streaming API on top of the same data.

When you migrate: add the `cursor` query param alongside `page`; serve
the cursor envelope when `cursor` is present, the offset envelope
otherwise. Consumers pick the matching SDK helper — `paginateCursor` /
`collectCursor` for the new shape, `paginate` / `collect` for legacy
offset. There is no envelope autodetection on the client; using the wrong
helper either misses rows or loops forever, so the two pairs are explicit
by design.

## Companion docs

- [Listing endpoints](/docs/reference/http-api) — every list endpoint
  with its current pagination shape.
- [SDK reference](/docs/reference/sdk-js) — `paginate`, `paginateCursor`,
  `collect`, `collectCursor`.
