Skip to main content
RapidDev - Software Development Agency
firebase-tutorial

How to Paginate Firestore Results

Firestore pagination uses cursor-based methods — startAfter and limit — to fetch data in pages. Unlike offset-based pagination, Firestore cursors use the last document snapshot from the previous page to determine where the next page begins. This approach is fast at any depth because it does not skip rows. Combine startAfter with orderBy and limit for forward pagination, and endBefore with limitToLast for backward pagination. Always include an orderBy clause to ensure consistent page ordering.

What you'll learn

  • How to implement forward pagination with startAfter and limit
  • How to implement backward pagination with endBefore and limitToLast
  • How to build a paginated list component in React
  • How to combine pagination with filters and compound queries
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Intermediate8 min read12-18 minFirebase JS SDK v9+, Firestore (all plans)March 2026RapidDev Engineering Team
TL;DR

Firestore pagination uses cursor-based methods — startAfter and limit — to fetch data in pages. Unlike offset-based pagination, Firestore cursors use the last document snapshot from the previous page to determine where the next page begins. This approach is fast at any depth because it does not skip rows. Combine startAfter with orderBy and limit for forward pagination, and endBefore with limitToLast for backward pagination. Always include an orderBy clause to ensure consistent page ordering.

Paginating Firestore Query Results

Firestore does not support traditional offset-based pagination. Instead, it uses cursor-based pagination where you pass the last document from the previous page to determine where the next page starts. This tutorial covers forward and backward pagination patterns, building a reusable pagination hook in React, and combining pagination with filters and sorting.

Prerequisites

  • A Firebase project with Firestore enabled and populated data
  • Firebase JS SDK v9+ installed in your project
  • Understanding of Firestore queries with where and orderBy
  • Basic React knowledge for the component examples

Step-by-step guide

1

Fetch the first page with orderBy and limit

Start by querying the first page of results. Use orderBy to define a consistent sort order and limit to set the page size. Store the last document snapshot from the results — this becomes the cursor for the next page. If the number of returned documents is less than the page size, you are on the last page.

typescript
1import { collection, query, orderBy, limit, getDocs } from 'firebase/firestore'
2
3const PAGE_SIZE = 10
4
5async function fetchFirstPage() {
6 const q = query(
7 collection(db, 'products'),
8 orderBy('createdAt', 'desc'),
9 limit(PAGE_SIZE)
10 )
11
12 const snapshot = await getDocs(q)
13 const docs = snapshot.docs.map((doc) => ({ id: doc.id, ...doc.data() }))
14
15 // Store the last document as cursor for next page
16 const lastDoc = snapshot.docs[snapshot.docs.length - 1]
17 const hasMore = snapshot.docs.length === PAGE_SIZE
18
19 return { docs, lastDoc, hasMore }
20}

Expected result: The first PAGE_SIZE documents are returned, sorted by createdAt descending, with a cursor for the next page.

2

Fetch the next page with startAfter

Pass the last document snapshot from the previous page to startAfter. This tells Firestore to begin the next query immediately after that document. The query must use the same orderBy clause as the first page. Continue fetching pages by updating the cursor after each query.

typescript
1import { startAfter } from 'firebase/firestore'
2
3async function fetchNextPage(lastDoc: any) {
4 const q = query(
5 collection(db, 'products'),
6 orderBy('createdAt', 'desc'),
7 startAfter(lastDoc),
8 limit(PAGE_SIZE)
9 )
10
11 const snapshot = await getDocs(q)
12 const docs = snapshot.docs.map((doc) => ({ id: doc.id, ...doc.data() }))
13
14 const newLastDoc = snapshot.docs[snapshot.docs.length - 1]
15 const hasMore = snapshot.docs.length === PAGE_SIZE
16
17 return { docs, lastDoc: newLastDoc, hasMore }
18}
19
20// Usage:
21// const page1 = await fetchFirstPage()
22// const page2 = await fetchNextPage(page1.lastDoc)
23// const page3 = await fetchNextPage(page2.lastDoc)

Expected result: Each subsequent page returns the next PAGE_SIZE documents starting after the previous page's last document.

3

Implement backward pagination with endBefore and limitToLast

To navigate backward (Previous page), use endBefore with the first document of the current page and limitToLast to get documents before that cursor. Store the first document snapshot of each page so you can navigate backward. This creates a stack-like navigation where each page push/pop is a cursor operation.

typescript
1import { endBefore, limitToLast } from 'firebase/firestore'
2
3async function fetchPreviousPage(firstDoc: any) {
4 const q = query(
5 collection(db, 'products'),
6 orderBy('createdAt', 'desc'),
7 endBefore(firstDoc),
8 limitToLast(PAGE_SIZE)
9 )
10
11 const snapshot = await getDocs(q)
12 const docs = snapshot.docs.map((doc) => ({ id: doc.id, ...doc.data() }))
13
14 return {
15 docs,
16 firstDoc: snapshot.docs[0],
17 lastDoc: snapshot.docs[snapshot.docs.length - 1],
18 }
19}

Expected result: The previous page of results is fetched using the first document of the current page as the backward cursor.

4

Combine pagination with filters

Add where clauses before the cursor methods to paginate filtered results. The orderBy field and any filtered fields may require a composite index, which Firestore prompts you to create. The cursor still works the same way — it positions within the filtered result set.

typescript
1import { where } from 'firebase/firestore'
2
3async function fetchFilteredPage(category: string, lastDoc?: any) {
4 const constraints: any[] = [
5 where('category', '==', category),
6 orderBy('price', 'asc'),
7 limit(PAGE_SIZE),
8 ]
9
10 if (lastDoc) {
11 constraints.push(startAfter(lastDoc))
12 }
13
14 const q = query(collection(db, 'products'), ...constraints)
15 const snapshot = await getDocs(q)
16
17 return {
18 docs: snapshot.docs.map((d) => ({ id: d.id, ...d.data() })),
19 lastDoc: snapshot.docs[snapshot.docs.length - 1] ?? null,
20 hasMore: snapshot.docs.length === PAGE_SIZE,
21 }
22}
23
24// Page through electronics sorted by price
25// const p1 = await fetchFilteredPage('electronics')
26// const p2 = await fetchFilteredPage('electronics', p1.lastDoc)

Expected result: Paginated results are filtered by category and sorted by price, with cursors working correctly within the filtered set.

5

Build a reusable pagination hook in React

Wrap the pagination logic in a custom React hook that manages the cursor stack, loading state, and page navigation. The hook accepts a Firestore query builder and page size, and returns the current page data along with next/previous functions.

typescript
1import { useState, useCallback } from 'react'
2import { getDocs, query, limit, startAfter, QueryConstraint, Query } from 'firebase/firestore'
3
4export function useFirestorePagination(
5 baseQuery: Query,
6 pageSize: number = 10
7) {
8 const [pages, setPages] = useState<any[][]>([])
9 const [cursors, setCursors] = useState<any[]>([])
10 const [loading, setLoading] = useState(false)
11 const [hasMore, setHasMore] = useState(true)
12 const [pageIndex, setPageIndex] = useState(-1)
13
14 const fetchPage = useCallback(async (cursor?: any) => {
15 setLoading(true)
16 const constraints: QueryConstraint[] = [limit(pageSize)]
17 if (cursor) constraints.push(startAfter(cursor))
18
19 const q = query(baseQuery, ...constraints)
20 const snapshot = await getDocs(q)
21 const docs = snapshot.docs.map((d) => ({ id: d.id, ...d.data() }))
22 const lastDoc = snapshot.docs[snapshot.docs.length - 1]
23
24 setHasMore(snapshot.docs.length === pageSize)
25 setLoading(false)
26 return { docs, lastDoc }
27 }, [baseQuery, pageSize])
28
29 const nextPage = useCallback(async () => {
30 const cursor = cursors[cursors.length - 1] ?? undefined
31 const { docs, lastDoc } = await fetchPage(cursor)
32 setPages((p) => [...p, docs])
33 setCursors((c) => [...c, lastDoc])
34 setPageIndex((i) => i + 1)
35 }, [fetchPage, cursors])
36
37 const prevPage = useCallback(() => {
38 if (pageIndex <= 0) return
39 setPageIndex((i) => i - 1)
40 setHasMore(true)
41 }, [pageIndex])
42
43 return {
44 data: pages[pageIndex] ?? [],
45 loading,
46 hasMore,
47 hasPrev: pageIndex > 0,
48 nextPage,
49 prevPage,
50 page: pageIndex + 1,
51 }
52}

Expected result: A reusable hook that manages Firestore cursor pagination with forward and backward navigation.

Complete working example

use-firestore-pagination.ts
1import { useState, useCallback, useEffect } from 'react'
2import {
3 getDocs,
4 query,
5 limit,
6 startAfter,
7 Query,
8 QueryConstraint,
9 DocumentSnapshot,
10} from 'firebase/firestore'
11
12interface PaginationResult<T> {
13 data: T[]
14 loading: boolean
15 hasMore: boolean
16 hasPrev: boolean
17 page: number
18 nextPage: () => Promise<void>
19 prevPage: () => void
20}
21
22export function useFirestorePagination<T>(
23 baseQuery: Query,
24 pageSize = 10
25): PaginationResult<T> {
26 const [pages, setPages] = useState<T[][]>([])
27 const [cursors, setCursors] = useState<DocumentSnapshot[]>([])
28 const [loading, setLoading] = useState(false)
29 const [hasMore, setHasMore] = useState(true)
30 const [pageIndex, setPageIndex] = useState(0)
31
32 const fetchPage = useCallback(
33 async (cursor?: DocumentSnapshot) => {
34 setLoading(true)
35 const c: QueryConstraint[] = [limit(pageSize)]
36 if (cursor) c.push(startAfter(cursor))
37
38 const snap = await getDocs(query(baseQuery, ...c))
39 const docs = snap.docs.map((d) => ({ id: d.id, ...d.data() } as T))
40 const last = snap.docs[snap.docs.length - 1]
41
42 setHasMore(snap.docs.length === pageSize)
43 setLoading(false)
44 return { docs, last }
45 },
46 [baseQuery, pageSize]
47 )
48
49 useEffect(() => {
50 fetchPage().then(({ docs, last }) => {
51 setPages([docs])
52 setCursors(last ? [last] : [])
53 setPageIndex(0)
54 })
55 }, [fetchPage])
56
57 const nextPage = useCallback(async () => {
58 if (pageIndex + 1 < pages.length) {
59 setPageIndex((i) => i + 1)
60 return
61 }
62 const cursor = cursors[pageIndex]
63 if (!cursor) return
64 const { docs, last } = await fetchPage(cursor)
65 setPages((p) => [...p, docs])
66 if (last) setCursors((c) => [...c, last])
67 setPageIndex((i) => i + 1)
68 }, [pageIndex, pages, cursors, fetchPage])
69
70 const prevPage = useCallback(() => {
71 if (pageIndex > 0) setPageIndex((i) => i - 1)
72 }, [pageIndex])
73
74 return {
75 data: pages[pageIndex] ?? [],
76 loading,
77 hasMore,
78 hasPrev: pageIndex > 0,
79 page: pageIndex + 1,
80 nextPage,
81 prevPage,
82 }
83}

Common mistakes when paginating Firestore Results

Why it's a problem: Trying to use numeric offset (skip N documents) which Firestore does not support

How to avoid: Use cursor-based pagination with startAfter/endBefore and document snapshots instead of numeric offsets.

Why it's a problem: Forgetting to include orderBy, causing inconsistent page ordering

How to avoid: Always include at least one orderBy clause before pagination cursors. Without ordering, document order is undefined and pages may overlap or skip documents.

Why it's a problem: Reusing a cursor from one filter when switching to a different filter

How to avoid: Reset the cursor to null whenever the query filters change. Start pagination from the beginning with the new filter applied.

Why it's a problem: Using startAt instead of startAfter, which includes the cursor document in the next page

How to avoid: Use startAfter to exclude the cursor document. startAt includes it, which means the last document of page N appears as the first document of page N+1.

Best practices

  • Always pair cursor methods with orderBy to ensure deterministic page ordering
  • Use startAfter (not startAt) to avoid duplicating the last document between pages
  • Store document snapshots as cursors rather than raw field values for safety with compound sorts
  • Reset cursors to null when filters change to restart pagination from the beginning
  • Cache previously fetched pages in state to allow instant backward navigation without re-fetching
  • Disable the Next button when hasMore is false and the Previous button on the first page
  • Consider infinite scroll for mobile UIs — append new pages to the list instead of replacing

Still stuck?

Copy one of these prompts to get a personalized, step-by-step explanation.

ChatGPT Prompt

Show me how to implement cursor-based pagination in Firestore using the Firebase modular SDK v9. I need forward pagination with startAfter/limit and backward pagination with endBefore/limitToLast. Include a React hook that manages the cursor stack and loading state.

Firebase Prompt

Create a paginated product listing page using Firestore. Show 12 products per page with Next and Previous buttons. Use orderBy('createdAt', 'desc') with cursor-based pagination via startAfter. Include filter by category that resets pagination when changed.

Frequently asked questions

Does Firestore support offset-based pagination?

No. Firestore does not have an offset or skip parameter. It only supports cursor-based pagination using startAt, startAfter, endAt, and endBefore with document snapshots or field values.

How do I show the total number of pages?

Firestore does not return a total count with queries. You need a separate count query using getCountFromServer, or maintain a document counter that you increment on writes. Without a total count, consider using a Load More button instead of numbered pages.

Can I jump to a specific page number?

Not directly. Cursor pagination only moves forward or backward from a known position. To jump to page 5, you would need to fetch pages 1-4 first (or store cursors for each page). For random access, consider maintaining a counter document or using a different approach.

How many documents should I fetch per page?

A page size of 10-25 documents works well for most applications. Larger pages reduce the number of queries but increase initial load time. For infinite scroll, 20-25 documents per batch provides a good balance between performance and user experience.

Does pagination work with real-time listeners?

Yes. You can use onSnapshot instead of getDocs with the same pagination queries. The listener will fire when documents in the current page change. However, new documents that should appear on the current page may not trigger a re-sort automatically.

Can RapidDev help implement paginated Firestore queries in my app?

Yes. RapidDev can build optimized pagination patterns for your specific data model, including filtered pagination, infinite scroll, and proper cursor management.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help with your project?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.