typescript 98 lines · 3 tabs

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.


Related snips

typescript
export interface RetryOptions {
  retries: number;
  baseMs: number;
  maxMs: number;
  signal?: AbortSignal;
  onRetry?: (attempt: number, delay: number, err: unknown) => void;

Exponential backoff with jitter for retries

typescript reliability retry
by codesnips 2 tabs
ruby
class PostsController < ApplicationController
  def index
    @posts = Post.includes(:author)
                 .order(created_at: :desc)
                 .page(params[:page])
                 .per(10)

Turbo Frames: infinite scroll with lazy-loading frame

rails turbo hotwire
by codesnips 4 tabs
typescript
import axios, { AxiosError } from 'axios'
import { v4 as uuidv4 } from 'uuid'

const api = axios.create({
  baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3000/api/v1',
  timeout: 15000,

Axios API client with interceptors

react axios api
by Maya Patel 1 tab
typescript
import { create } from 'zustand'
import { persist } from 'zustand/middleware'

interface FilterState {
  search: string
  category: string | null

Zustand for lightweight state management

react zustand state-management
by Maya Patel 2 tabs
typescript
import { randomBytes, createHash } from "crypto";
import jwt from "jsonwebtoken";
import { RefreshTokenStore } from "./store";

const ACCESS_SECRET = process.env.ACCESS_SECRET!;
const ACCESS_TTL = "15m";

JWT access + refresh token rotation (conceptual)

security node jwt
by codesnips 3 tabs
typescript
import React from "react";

type FallbackProps = {
  error: Error;
  reset: () => void;
};

React Error Boundary + error reporting hook

react frontend error-boundary
by codesnips 3 tabs

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