Skip to main content
FirestoreORM supports two pagination strategies: cursor-based pagination (recommended) and offset-based pagination. This guide covers both approaches with practical examples. Cursor-based pagination uses document IDs to mark your position in the result set. This is the most efficient approach for large datasets.

How It Works

1

Fetch First Page

Request the first batch of documents with a limit.
2

Get Cursor

Save the ID of the last document from the result.
3

Fetch Next Page

Use the cursor ID to start after that document.
4

Repeat

Continue until nextCursorId is undefined.

Basic Cursor Pagination

// First page
const { items, nextCursorId } = await userRepo.query()
  .orderBy('createdAt', 'desc')
  .paginate(20);

console.log(`Fetched ${items.length} users`);
console.log(`Next cursor: ${nextCursorId}`);

// Second page
if (nextCursorId) {
  const page2 = await userRepo.query()
    .orderBy('createdAt', 'desc')
    .paginate(20, nextCursorId);
  
  console.log(`Fetched ${page2.items.length} more users`);
}

Pagination with Filters

Combine pagination with where clauses for filtered results.
const { items, nextCursorId } = await productRepo.query()
  .where('category', '==', 'electronics')
  .where('inStock', '==', true)
  .orderBy('price', 'desc')
  .paginate(20, cursorId);
You must include the same where() and orderBy() clauses for all pages, or results will be inconsistent.

Complete Pagination Example

interface PaginationState {
  items: Product[];
  hasMore: boolean;
  cursor?: string;
}

class ProductService {
  async getProducts(
    category: string,
    pageSize: number = 20,
    cursor?: string
  ): Promise<PaginationState> {
    const { items, nextCursorId } = await productRepo.query()
      .where('category', '==', category)
      .where('inStock', '==', true)
      .orderBy('price', 'asc')
      .paginate(pageSize, cursor);
    
    return {
      items,
      hasMore: nextCursorId !== undefined,
      cursor: nextCursorId
    };
  }
}

// Usage in your API
app.get('/api/products', async (req, res) => {
  const { category, cursor } = req.query;
  const result = await productService.getProducts(category, 20, cursor);
  res.json(result);
});

Pagination with Total Count

Get paginated results along with the total count.
const { items, nextCursorId, total } = await productRepo.query()
  .where('category', '==', 'electronics')
  .paginateWithCount(20, cursorId);

console.log(`Showing ${items.length} of ${total} products`);
console.log(`Has more: ${nextCursorId !== undefined}`);
paginateWithCount() runs an additional count() query, which adds to your Firestore costs. Use it only when you need to display total counts.

Bi-Directional Pagination

Navigate both forward and backward through results.
class PaginationHelper {
  async getPage(
    pageSize: number,
    direction: 'next' | 'prev',
    cursorId?: string
  ) {
    const query = userRepo.query()
      .orderBy('createdAt', 'desc')
      .limit(pageSize);
    
    if (direction === 'next' && cursorId) {
      // Start after cursor
      await query.startAfterId(cursorId);
    } else if (direction === 'prev' && cursorId) {
      // Reverse order and start after cursor
      // Then reverse results again
      // (Requires custom implementation)
    }
    
    return query.get();
  }
}

Offset-Based Pagination

Offset pagination uses page numbers and skips documents to reach the desired page. This is less efficient for large datasets but simpler for traditional pagination UIs.

Basic Offset Pagination

// Page 1 (0 skip)
const page1 = await userRepo.query()
  .where('status', '==', 'active')
  .orderBy('createdAt', 'desc')
  .offsetPaginate(1, 20);

console.log(page1);
// {
//   items: [...],
//   page: 1,
//   pageSize: 20,
//   total: 156,
//   totalPages: 8
// }

// Page 2 (20 skip)
const page2 = await userRepo.query()
  .where('status', '==', 'active')
  .orderBy('createdAt', 'desc')
  .offsetPaginate(2, 20);

Offset Pagination with Metadata

const result = await orderRepo.query()
  .where('status', '==', 'completed')
  .orderBy('createdAt', 'desc')
  .offsetPaginate(page, 20);

console.log({
  items: result.items,
  currentPage: result.page,
  totalPages: result.totalPages,
  totalItems: result.total,
  hasNextPage: result.page < result.totalPages,
  hasPrevPage: result.page > 1
});

API Endpoint with Offset Pagination

app.get('/api/users', async (req, res) => {
  const page = parseInt(req.query.page as string) || 1;
  const limit = parseInt(req.query.limit as string) || 20;
  
  const result = await userRepo.query()
    .where('status', '==', 'active')
    .orderBy('createdAt', 'desc')
    .offsetPaginate(page, limit);
  
  res.json({
    data: result.items,
    pagination: {
      page: result.page,
      pageSize: result.pageSize,
      totalPages: result.totalPages,
      total: result.total
    }
  });
});
Offset pagination becomes slower as page numbers increase because Firestore must scan and skip all previous documents.

Performance Comparison

Cursor Pagination Cost

// First page: 20 reads + 1 cursor read
const page1 = await userRepo.query()
  .orderBy('createdAt', 'desc')
  .paginate(20);
// Cost: 20 document reads

// Second page: 20 reads + 1 cursor read
const page2 = await userRepo.query()
  .orderBy('createdAt', 'desc')
  .paginate(20, page1.nextCursorId);
// Cost: 21 document reads (20 + 1 for cursor lookup)

Offset Pagination Cost

// Page 1: 20 reads + 1 count query
const page1 = await userRepo.query()
  .orderBy('createdAt', 'desc')
  .offsetPaginate(1, 20);
// Cost: 20 document reads + count aggregation

// Page 5: 100 skipped + 20 reads + 1 count query
const page5 = await userRepo.query()
  .orderBy('createdAt', 'desc')
  .offsetPaginate(5, 20);
// Cost: 120 document reads + count aggregation

// Page 100: 1980 skipped + 20 reads + 1 count query
const page100 = await userRepo.query()
  .orderBy('createdAt', 'desc')
  .offsetPaginate(100, 20);
// Cost: 2000 document reads + count aggregation (!)
Key Insight: Cursor pagination has constant cost per page, while offset pagination cost grows linearly with page number.

Simple List Pagination

For basic use cases, use the repository’s list() method.
// First 20 users
const firstPage = await userRepo.list(20);

// Next 20 users
const lastId = firstPage[firstPage.length - 1]?.id;
const nextPage = await userRepo.list(20, lastId);

// Include soft-deleted documents
const allUsers = await userRepo.list(50, undefined, true);
list() is a simplified wrapper around cursor pagination. For more control, use query().paginate().

Infinite Scroll Implementation

React Example

import { useState, useEffect } from 'react';

function UserList() {
  const [users, setUsers] = useState<User[]>([]);
  const [cursor, setCursor] = useState<string | undefined>();
  const [loading, setLoading] = useState(false);
  const [hasMore, setHasMore] = useState(true);
  
  const loadMore = async () => {
    if (loading || !hasMore) return;
    
    setLoading(true);
    try {
      const response = await fetch(
        `/api/users?cursor=${cursor || ''}`
      );
      const data = await response.json();
      
      setUsers(prev => [...prev, ...data.items]);
      setCursor(data.nextCursor);
      setHasMore(!!data.nextCursor);
    } finally {
      setLoading(false);
    }
  };
  
  useEffect(() => {
    loadMore();
  }, []);
  
  return (
    <div>
      {users.map(user => (
        <UserCard key={user.id} user={user} />
      ))}
      {hasMore && (
        <button onClick={loadMore} disabled={loading}>
          {loading ? 'Loading...' : 'Load More'}
        </button>
      )}
    </div>
  );
}

Backend API

app.get('/api/users', async (req, res) => {
  const cursor = req.query.cursor as string | undefined;
  
  const { items, nextCursorId } = await userRepo.query()
    .where('status', '==', 'active')
    .orderBy('createdAt', 'desc')
    .paginate(20, cursor);
  
  res.json({
    items,
    nextCursor: nextCursorId,
    hasMore: !!nextCursorId
  });
});

Traditional Page Numbers

Page Number UI

interface PaginationProps {
  currentPage: number;
  totalPages: number;
  onPageChange: (page: number) => void;
}

function Pagination({ currentPage, totalPages, onPageChange }: PaginationProps) {
  const pages = Array.from({ length: totalPages }, (_, i) => i + 1);
  
  return (
    <div className="pagination">
      <button 
        onClick={() => onPageChange(currentPage - 1)}
        disabled={currentPage === 1}
      >
        Previous
      </button>
      
      {pages.map(page => (
        <button
          key={page}
          onClick={() => onPageChange(page)}
          className={page === currentPage ? 'active' : ''}
        >
          {page}
        </button>
      ))}
      
      <button
        onClick={() => onPageChange(currentPage + 1)}
        disabled={currentPage === totalPages}
      >
        Next
      </button>
    </div>
  );
}

Usage

function UserTable() {
  const [page, setPage] = useState(1);
  const [data, setData] = useState(null);
  
  useEffect(() => {
    async function loadPage() {
      const result = await userRepo.query()
        .where('status', '==', 'active')
        .orderBy('createdAt', 'desc')
        .offsetPaginate(page, 20);
      setData(result);
    }
    loadPage();
  }, [page]);
  
  if (!data) return <div>Loading...</div>;
  
  return (
    <div>
      <table>
        {data.items.map(user => (
          <tr key={user.id}>
            <td>{user.name}</td>
            <td>{user.email}</td>
          </tr>
        ))}
      </table>
      
      <Pagination
        currentPage={data.page}
        totalPages={data.totalPages}
        onPageChange={setPage}
      />
    </div>
  );
}

Best Practices

1

Use Cursor Pagination for Large Datasets

Cursor pagination has constant performance regardless of page depth.
// ✅ Good - scales well
await userRepo.query()
  .orderBy('createdAt', 'desc')
  .paginate(20, cursor);

// ❌ Slow for high page numbers
await userRepo.query()
  .orderBy('createdAt', 'desc')
  .offsetPaginate(100, 20);
2

Always Include orderBy() for Consistent Results

Without ordering, document order is undefined and can change.
// ✅ Predictable pagination
await userRepo.query()
  .orderBy('createdAt', 'desc')
  .paginate(20);

// ❌ Unpredictable results
await userRepo.query()
  .paginate(20);
3

Keep Page Sizes Reasonable

Balance between number of requests and data per request.
// ✅ Good - 20-50 items per page
await userRepo.query().paginate(20);

// ❌ Too small - too many requests
await userRepo.query().paginate(5);

// ❌ Too large - slow loading
await userRepo.query().paginate(1000);
4

Cache Total Counts

Don’t recalculate total count on every page request.
// Cache for 5 minutes
const cachedTotal = await cache.get('user_count');
if (!cachedTotal) {
  const total = await userRepo.query().count();
  await cache.set('user_count', total, 300);
}

Choosing the Right Strategy

Use Cursor Pagination When

  • Implementing infinite scroll
  • Working with large datasets (1000+ documents)
  • Performance is critical
  • Users mostly navigate forward

Use Offset Pagination When

  • Implementing traditional page numbers
  • Dataset is small (fewer than 1000 documents)
  • Users need to jump to specific pages
  • You need total page count

Next Steps

Queries

Build complex queries with filtering and sorting

Streaming

Process large result sets efficiently with streaming

Real-time Updates

Subscribe to live query updates

Performance

Optimize your queries and reduce costs

Build docs developers (and LLMs) love