Lesson 11 / 25

Pagination With Cursors

startAfter, not offsets.

Cursor-based pages

Paginate by ordering on a field and using a query cursor: keep the last document snapshot of the current page and pass it to startAfter for the next page. Cursors are stable when new data arrives and only read the documents you return. Avoid offset-style approaches, because skipped documents are still read and billed in Firestore. Order on a field that is unique, or add a tiebreaker such as the document id, so documents with equal values are not skipped or repeated. startAt, endAt and endBefore are also available, plus limitToLast for paging backwards.

Load more

TypeScript.

import {
  collection, query, orderBy, limit, startAfter, getDocs,
  QueryDocumentSnapshot, DocumentData,
} from "firebase/firestore";

const PAGE = 20;
let last: QueryDocumentSnapshot<DocumentData> | undefined;

export async function loadMore() {
  const posts = collection(db, "posts");
  const q = last
    ? query(posts, orderBy("createdAt", "desc"), startAfter(last), limit(PAGE))
    : query(posts, orderBy("createdAt", "desc"), limit(PAGE));

  const snap = await getDocs(q);
  last = snap.docs[snap.docs.length - 1] ?? last;
  return { items: snap.docs.map((d) => ({ id: d.id, ...d.data() })), done: snap.size < PAGE };
}

A bookmark, not a page number

You remember the last line you read and continue from there, instead of counting from the start of the book every time.

Quick check: Why prefer startAfter over skipping N documents?

  • Cursors bypass Security Rules
  • startAfter works without any ordering
  • Offsets are not allowed in JavaScript
  • Skipped documents are still read and billed, while cursors read only the page
Answer

Skipped documents are still read and billed, while cursors read only the page — Cursors are cheaper and stable.