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
Fetch the first page with orderBy and limit
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.
1import { collection, query, orderBy, limit, getDocs } from 'firebase/firestore'23const PAGE_SIZE = 1045async function fetchFirstPage() {6 const q = query(7 collection(db, 'products'),8 orderBy('createdAt', 'desc'),9 limit(PAGE_SIZE)10 )1112 const snapshot = await getDocs(q)13 const docs = snapshot.docs.map((doc) => ({ id: doc.id, ...doc.data() }))1415 // Store the last document as cursor for next page16 const lastDoc = snapshot.docs[snapshot.docs.length - 1]17 const hasMore = snapshot.docs.length === PAGE_SIZE1819 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.
Fetch the next page with startAfter
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.
1import { startAfter } from 'firebase/firestore'23async function fetchNextPage(lastDoc: any) {4 const q = query(5 collection(db, 'products'),6 orderBy('createdAt', 'desc'),7 startAfter(lastDoc),8 limit(PAGE_SIZE)9 )1011 const snapshot = await getDocs(q)12 const docs = snapshot.docs.map((doc) => ({ id: doc.id, ...doc.data() }))1314 const newLastDoc = snapshot.docs[snapshot.docs.length - 1]15 const hasMore = snapshot.docs.length === PAGE_SIZE1617 return { docs, lastDoc: newLastDoc, hasMore }18}1920// 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.
Implement backward pagination with endBefore and limitToLast
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.
1import { endBefore, limitToLast } from 'firebase/firestore'23async function fetchPreviousPage(firstDoc: any) {4 const q = query(5 collection(db, 'products'),6 orderBy('createdAt', 'desc'),7 endBefore(firstDoc),8 limitToLast(PAGE_SIZE)9 )1011 const snapshot = await getDocs(q)12 const docs = snapshot.docs.map((doc) => ({ id: doc.id, ...doc.data() }))1314 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.
Combine pagination with filters
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.
1import { where } from 'firebase/firestore'23async function fetchFilteredPage(category: string, lastDoc?: any) {4 const constraints: any[] = [5 where('category', '==', category),6 orderBy('price', 'asc'),7 limit(PAGE_SIZE),8 ]910 if (lastDoc) {11 constraints.push(startAfter(lastDoc))12 }1314 const q = query(collection(db, 'products'), ...constraints)15 const snapshot = await getDocs(q)1617 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}2324// Page through electronics sorted by price25// 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.
Build a reusable pagination hook in React
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.
1import { useState, useCallback } from 'react'2import { getDocs, query, limit, startAfter, QueryConstraint, Query } from 'firebase/firestore'34export function useFirestorePagination(5 baseQuery: Query,6 pageSize: number = 107) {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)1314 const fetchPage = useCallback(async (cursor?: any) => {15 setLoading(true)16 const constraints: QueryConstraint[] = [limit(pageSize)]17 if (cursor) constraints.push(startAfter(cursor))1819 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]2324 setHasMore(snapshot.docs.length === pageSize)25 setLoading(false)26 return { docs, lastDoc }27 }, [baseQuery, pageSize])2829 const nextPage = useCallback(async () => {30 const cursor = cursors[cursors.length - 1] ?? undefined31 const { docs, lastDoc } = await fetchPage(cursor)32 setPages((p) => [...p, docs])33 setCursors((c) => [...c, lastDoc])34 setPageIndex((i) => i + 1)35 }, [fetchPage, cursors])3637 const prevPage = useCallback(() => {38 if (pageIndex <= 0) return39 setPageIndex((i) => i - 1)40 setHasMore(true)41 }, [pageIndex])4243 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
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'1112interface PaginationResult<T> {13 data: T[]14 loading: boolean15 hasMore: boolean16 hasPrev: boolean17 page: number18 nextPage: () => Promise<void>19 prevPage: () => void20}2122export function useFirestorePagination<T>(23 baseQuery: Query,24 pageSize = 1025): 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)3132 const fetchPage = useCallback(33 async (cursor?: DocumentSnapshot) => {34 setLoading(true)35 const c: QueryConstraint[] = [limit(pageSize)]36 if (cursor) c.push(startAfter(cursor))3738 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]4142 setHasMore(snap.docs.length === pageSize)43 setLoading(false)44 return { docs, last }45 },46 [baseQuery, pageSize]47 )4849 useEffect(() => {50 fetchPage().then(({ docs, last }) => {51 setPages([docs])52 setCursors(last ? [last] : [])53 setPageIndex(0)54 })55 }, [fetchPage])5657 const nextPage = useCallback(async () => {58 if (pageIndex + 1 < pages.length) {59 setPageIndex((i) => i + 1)60 return61 }62 const cursor = cursors[pageIndex]63 if (!cursor) return64 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])6970 const prevPage = useCallback(() => {71 if (pageIndex > 0) setPageIndex((i) => i - 1)72 }, [pageIndex])7374 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.
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.
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation