Cursor Pagination for a REST List Endpoint with a Typed Fetch Client

Shared by codesnips Jul 2026
3 tabs
export interface Cursor {
  createdAt: string;
  id: string;
}

export function encodeCursor(c: Cursor): string {
  const json = JSON.stringify(c);
  return Buffer.from(json, "utf8").toString("base64url");
}

export function decodeCursor(token: string): Cursor | null {
  try {
    const json = Buffer.from(token, "base64url").toString("utf8");
    const parsed = JSON.parse(json);
    if (typeof parsed?.createdAt === "string" && typeof parsed?.id === "string") {
      return { createdAt: parsed.createdAt, id: parsed.id };
    }
    return null;
  } catch {
    return null;
  }
}
3 files · typescript Explain with highlit

Cursor pagination avoids the correctness and performance problems of offset pagination (OFFSET N) on large, mutating datasets. Instead of counting rows to skip, the server remembers the position of the last returned item and asks the database for rows after that position using an index-friendly WHERE clause. This keeps each page fast regardless of depth and prevents items from being skipped or duplicated when rows are inserted or deleted between requests.

In cursor.ts, the cursor is treated as an opaque token: encodeCursor serializes a { createdAt, id } pair to base64, and decodeCursor parses it back, returning null on malformed input rather than throwing. Encoding both a timestamp and a tiebreaker id is deliberate — createdAt alone is not unique, so the composite key gives a stable, total ordering. Treating the token as opaque means clients never construct it themselves and the server is free to change its internal shape later.

postsController.ts exposes listPosts, which reads a limit (clamped between 1 and 100 to bound the query cost) and an optional cursor. It fetches limit + 1 rows — the extra row is a cheap probe that reveals whether another page exists without a separate COUNT. The keyset predicate is expressed with Prisma's compound OR: rows with an older createdAt, or the same createdAt but a smaller id. If the batch overflows, the surplus row is popped off and its position becomes nextCursor; otherwise nextCursor is null, signalling the end. The response shape { data, nextCursor, hasMore } is the entire contract a client needs.

postsClient.ts wraps that contract in a typed fetch helper. PageResult<T> mirrors the server envelope, and listPosts forwards the caller's cursor and limit as query parameters, throwing on a non-2xx status so callers can rely on the happy path. The generator iteratePosts is where the pattern pays off: it hides the loop entirely, yielding one Post at a time and transparently following nextCursor until hasMore is false. Consumers write for await (const post of iteratePosts()) and never touch a token.

A few trade-offs are worth noting. Cursor pagination cannot jump to an arbitrary page number, so it suits infinite-scroll and export-style traversal rather than numbered page UIs. The sort key must be immutable and backed by an index — sorting by a mutable column would let rows drift across page boundaries. Because the composite (createdAt, id) ordering here is deterministic, the traversal is stable even under concurrent writes, which is exactly why this approach is preferred for feeds, activity logs, and large API exports.

Share this code

Here's the card — post it anywhere.

Cursor Pagination for a REST List Endpoint with a Typed Fetch Client — share card
Link copied