# How to Paginate Firestore Results

- Tool: Firebase
- Difficulty: Intermediate
- Time required: 12-18 min
- Compatibility: Firebase JS SDK v9+, Firestore (all plans)
- Last updated: March 2026

## 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.

## Before you start

- 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.

```
import { collection, query, orderBy, limit, getDocs } from 'firebase/firestore'

const PAGE_SIZE = 10

async function fetchFirstPage() {
  const q = query(
    collection(db, 'products'),
    orderBy('createdAt', 'desc'),
    limit(PAGE_SIZE)
  )

  const snapshot = await getDocs(q)
  const docs = snapshot.docs.map((doc) => ({ id: doc.id, ...doc.data() }))

  // Store the last document as cursor for next page
  const lastDoc = snapshot.docs[snapshot.docs.length - 1]
  const hasMore = snapshot.docs.length === PAGE_SIZE

  return { docs, lastDoc, hasMore }
}
```

**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.

```
import { startAfter } from 'firebase/firestore'

async function fetchNextPage(lastDoc: any) {
  const q = query(
    collection(db, 'products'),
    orderBy('createdAt', 'desc'),
    startAfter(lastDoc),
    limit(PAGE_SIZE)
  )

  const snapshot = await getDocs(q)
  const docs = snapshot.docs.map((doc) => ({ id: doc.id, ...doc.data() }))

  const newLastDoc = snapshot.docs[snapshot.docs.length - 1]
  const hasMore = snapshot.docs.length === PAGE_SIZE

  return { docs, lastDoc: newLastDoc, hasMore }
}

// Usage:
// const page1 = await fetchFirstPage()
// const page2 = await fetchNextPage(page1.lastDoc)
// 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.

```
import { endBefore, limitToLast } from 'firebase/firestore'

async function fetchPreviousPage(firstDoc: any) {
  const q = query(
    collection(db, 'products'),
    orderBy('createdAt', 'desc'),
    endBefore(firstDoc),
    limitToLast(PAGE_SIZE)
  )

  const snapshot = await getDocs(q)
  const docs = snapshot.docs.map((doc) => ({ id: doc.id, ...doc.data() }))

  return {
    docs,
    firstDoc: snapshot.docs[0],
    lastDoc: snapshot.docs[snapshot.docs.length - 1],
  }
}
```

**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.

```
import { where } from 'firebase/firestore'

async function fetchFilteredPage(category: string, lastDoc?: any) {
  const constraints: any[] = [
    where('category', '==', category),
    orderBy('price', 'asc'),
    limit(PAGE_SIZE),
  ]

  if (lastDoc) {
    constraints.push(startAfter(lastDoc))
  }

  const q = query(collection(db, 'products'), ...constraints)
  const snapshot = await getDocs(q)

  return {
    docs: snapshot.docs.map((d) => ({ id: d.id, ...d.data() })),
    lastDoc: snapshot.docs[snapshot.docs.length - 1] ?? null,
    hasMore: snapshot.docs.length === PAGE_SIZE,
  }
}

// Page through electronics sorted by price
// const p1 = await fetchFilteredPage('electronics')
// 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.

```
import { useState, useCallback } from 'react'
import { getDocs, query, limit, startAfter, QueryConstraint, Query } from 'firebase/firestore'

export function useFirestorePagination(
  baseQuery: Query,
  pageSize: number = 10
) {
  const [pages, setPages] = useState<any[][]>([])
  const [cursors, setCursors] = useState<any[]>([])
  const [loading, setLoading] = useState(false)
  const [hasMore, setHasMore] = useState(true)
  const [pageIndex, setPageIndex] = useState(-1)

  const fetchPage = useCallback(async (cursor?: any) => {
    setLoading(true)
    const constraints: QueryConstraint[] = [limit(pageSize)]
    if (cursor) constraints.push(startAfter(cursor))

    const q = query(baseQuery, ...constraints)
    const snapshot = await getDocs(q)
    const docs = snapshot.docs.map((d) => ({ id: d.id, ...d.data() }))
    const lastDoc = snapshot.docs[snapshot.docs.length - 1]

    setHasMore(snapshot.docs.length === pageSize)
    setLoading(false)
    return { docs, lastDoc }
  }, [baseQuery, pageSize])

  const nextPage = useCallback(async () => {
    const cursor = cursors[cursors.length - 1] ?? undefined
    const { docs, lastDoc } = await fetchPage(cursor)
    setPages((p) => [...p, docs])
    setCursors((c) => [...c, lastDoc])
    setPageIndex((i) => i + 1)
  }, [fetchPage, cursors])

  const prevPage = useCallback(() => {
    if (pageIndex <= 0) return
    setPageIndex((i) => i - 1)
    setHasMore(true)
  }, [pageIndex])

  return {
    data: pages[pageIndex] ?? [],
    loading,
    hasMore,
    hasPrev: pageIndex > 0,
    nextPage,
    prevPage,
    page: pageIndex + 1,
  }
}
```

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

## Complete code example

File: `use-firestore-pagination.ts`

```typescript
import { useState, useCallback, useEffect } from 'react'
import {
  getDocs,
  query,
  limit,
  startAfter,
  Query,
  QueryConstraint,
  DocumentSnapshot,
} from 'firebase/firestore'

interface PaginationResult<T> {
  data: T[]
  loading: boolean
  hasMore: boolean
  hasPrev: boolean
  page: number
  nextPage: () => Promise<void>
  prevPage: () => void
}

export function useFirestorePagination<T>(
  baseQuery: Query,
  pageSize = 10
): PaginationResult<T> {
  const [pages, setPages] = useState<T[][]>([])
  const [cursors, setCursors] = useState<DocumentSnapshot[]>([])
  const [loading, setLoading] = useState(false)
  const [hasMore, setHasMore] = useState(true)
  const [pageIndex, setPageIndex] = useState(0)

  const fetchPage = useCallback(
    async (cursor?: DocumentSnapshot) => {
      setLoading(true)
      const c: QueryConstraint[] = [limit(pageSize)]
      if (cursor) c.push(startAfter(cursor))

      const snap = await getDocs(query(baseQuery, ...c))
      const docs = snap.docs.map((d) => ({ id: d.id, ...d.data() } as T))
      const last = snap.docs[snap.docs.length - 1]

      setHasMore(snap.docs.length === pageSize)
      setLoading(false)
      return { docs, last }
    },
    [baseQuery, pageSize]
  )

  useEffect(() => {
    fetchPage().then(({ docs, last }) => {
      setPages([docs])
      setCursors(last ? [last] : [])
      setPageIndex(0)
    })
  }, [fetchPage])

  const nextPage = useCallback(async () => {
    if (pageIndex + 1 < pages.length) {
      setPageIndex((i) => i + 1)
      return
    }
    const cursor = cursors[pageIndex]
    if (!cursor) return
    const { docs, last } = await fetchPage(cursor)
    setPages((p) => [...p, docs])
    if (last) setCursors((c) => [...c, last])
    setPageIndex((i) => i + 1)
  }, [pageIndex, pages, cursors, fetchPage])

  const prevPage = useCallback(() => {
    if (pageIndex > 0) setPageIndex((i) => i - 1)
  }, [pageIndex])

  return {
    data: pages[pageIndex] ?? [],
    loading,
    hasMore,
    hasPrev: pageIndex > 0,
    page: pageIndex + 1,
    nextPage,
    prevPage,
  }
}
```

## Common mistakes

- **Trying to use numeric offset (skip N documents) which Firestore does not support** — undefined Fix: Use cursor-based pagination with startAfter/endBefore and document snapshots instead of numeric offsets.
- **Forgetting to include orderBy, causing inconsistent page ordering** — undefined Fix: Always include at least one orderBy clause before pagination cursors. Without ordering, document order is undefined and pages may overlap or skip documents.
- **Reusing a cursor from one filter when switching to a different filter** — undefined Fix: Reset the cursor to null whenever the query filters change. Start pagination from the beginning with the new filter applied.
- **Using startAt instead of startAfter, which includes the cursor document in the next page** — undefined Fix: 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

## 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.

---

Source: https://www.rapidevelopers.com/firebase-tutorial/how-to-paginate-firestore-results
© RapidDev — https://www.rapidevelopers.com/firebase-tutorial/how-to-paginate-firestore-results
