Cursor pagination walks through a large result set one page at a time, where each response carries an opaque cursor marking where the next page begins. It is how feeds at GitHub, Stripe, and Slack are paged: instead of asking for "page 5" by number, you hand back the cursor the server gave you last time and it returns the rows that come after it. Your job is to wrap that back-and-forth in a small stateful fetcher that threads the cursor for you, accumulates the items, and knows when it has reached the end.
type Page<T> = { items: T[]; nextCursor: unknown };
function cursorPaginationFetcher<T>(
fetchPage: (cursor: unknown) => Promise<Page<T>>,
): {
next(): Promise<T[]>; // fetch the next page; resolves with THAT page's items
all(): Promise<T[]>; // fetch pages until exhausted; resolves with everything
reset(): void; // clear items + cursor; start over from the first page
items: T[]; // every item gathered so far (live)
hasMore: boolean; // is there at least one more page? (live)
};
The first fetchPage call receives null. A response whose nextCursor is null or undefined marks the last page.
// Three pages: cursors thread through, and a null nextCursor ends it.
const db = {
first: { items: ['a', 'b'], nextCursor: 'c1' },
c1: { items: ['c', 'd'], nextCursor: 'c2' },
c2: { items: ['e'], nextCursor: null },
};
// The first call gets null, so map it to the first page.
const fetchPage = (cursor) => Promise.resolve(db[cursor ?? 'first']);
const fetcher = cursorPaginationFetcher(fetchPage);
await fetcher.next(); // ['a', 'b'] -> items ['a','b'], hasMore true
await fetcher.next(); // ['c', 'd'] -> items ['a','b','c','d'], hasMore true
await fetcher.next(); // ['e'] -> items ['a','b','c','d','e'], hasMore false
await fetcher.next(); // [] -> exhausted, no fetch is made
// all() drains every remaining page in one call; reset() rewinds to the start.
const fetcher = cursorPaginationFetcher(fetchPage);
const everything = await fetcher.all(); // ['a','b','c','d','e']
fetcher.hasMore; // false
fetcher.reset();
fetcher.hasMore; // true again — back to the first page
nextCursor as the argument to the next fetchPage call; the first call gets null.items is the union of every page fetched so far, not just the latest page.hasMore tracks the cursor — it stays true until a response returns a nullish nextCursor (null or undefined), then flips to false.next() is a no-op — once hasMore is false, calling next() resolves with [] and does not call fetchPage.next() versus all() — next() resolves with just the page it fetched; all() resolves with the full accumulated list.next() calls.