Firebase Integration

Cloud Firestore — Queries, Filters & Pagination

16 min Lesson 6 of 13

Cloud Firestore — Queries, Filters & Pagination

Firestore is not just a key-value store — it is a powerful NoSQL document database with a rich querying API. This lesson covers how to build precise, compound queries using where(), orderBy(), and limit() clauses, how to implement cursor-based pagination with startAfterDocument(), and why composite indexes matter for production apps.

Basic Filtering with where()

The where() clause filters documents based on field values. Firestore supports the following comparison operators:

  • isEqualTo, isNotEqualTo
  • isLessThan, isLessThanOrEqualTo
  • isGreaterThan, isGreaterThanOrEqualTo
  • arrayContains, arrayContainsAny
  • whereIn, whereNotIn

Simple where() Query

import 'package:cloud_firestore/cloud_firestore.dart';

Future<List<Map<String, dynamic>>> getPublishedPosts() async {
  final snapshot = await FirebaseFirestore.instance
      .collection('posts')
      .where('status', isEqualTo: 'published')
      .where('viewCount', isGreaterThan: 100)
      .get();

  return snapshot.docs
      .map((doc) => {'id': doc.id, ...doc.data()})
      .toList();
}
Note: Chaining multiple where() calls creates a logical AND between the conditions. Firestore does not natively support OR across different fields — for that pattern you must run two separate queries and merge the results in Dart.

Sorting Results with orderBy()

The orderBy() clause sorts the returned documents. You can chain multiple orderBy() calls to apply secondary sort keys. Pass descending: true to reverse the order.

Warning: If you use a where() with an inequality operator (isLessThan, isGreaterThan, etc.) on a field, the first orderBy() in the chain must sort on that same field. Failing to do so will throw a runtime exception.

Compound Query with orderBy() and limit()

Future<QuerySnapshot<Map<String, dynamic>>> getTopRatedProducts() async {
  return FirebaseFirestore.instance
      .collection('products')
      .where('inStock', isEqualTo: true)
      .where('rating', isGreaterThanOrEqualTo: 4.0)
      .orderBy('rating', descending: true)   // must order by the inequality field first
      .orderBy('createdAt', descending: true) // then secondary sort
      .limit(20)
      .get();
}

Cursor-Based Pagination with startAfterDocument()

Offset-based pagination (offset: n) is not supported in Firestore. Instead, Firestore uses query cursors to page through results efficiently. The key method is startAfterDocument(lastDoc), which resumes the query starting from the document after the last one you already fetched.

  • startAfterDocument(doc) — start after the given document (exclusive)
  • startAtDocument(doc) — start at the given document (inclusive)
  • endBeforeDocument(doc) — stop before the document
  • endAtDocument(doc) — stop at the document (inclusive)

Paginated Feed with startAfterDocument()

class PostFeed {
  static const int _pageSize = 10;
  DocumentSnapshot? _lastDocument;
  bool _hasMore = true;
  final List<Map<String, dynamic>> _posts = [];

  Future<void> loadNextPage() async {
    if (!_hasMore) return;

    Query<Map<String, dynamic>> query = FirebaseFirestore.instance
        .collection('posts')
        .where('status', isEqualTo: 'published')
        .orderBy('publishedAt', descending: true)
        .limit(_pageSize);

    // Apply cursor if this is not the first page
    if (_lastDocument != null) {
      query = query.startAfterDocument(_lastDocument!);
    }

    final snapshot = await query.get();

    if (snapshot.docs.length < _pageSize) {
      _hasMore = false; // reached the end of the collection
    }

    if (snapshot.docs.isNotEmpty) {
      _lastDocument = snapshot.docs.last;
      _posts.addAll(snapshot.docs.map((d) => {'id': d.id, ...d.data()}));
    }
  }

  List<Map<String, dynamic>> get posts => List.unmodifiable(_posts);
  bool get hasMore => _hasMore;
}
Tip: Store the entire DocumentSnapshot object as your cursor — not just the document ID or a field value. Firestore uses the snapshot internally to resolve the precise cursor position, including tie-breaking on secondary sort fields. Losing the snapshot reference means you cannot paginate correctly.

Composite Indexes

Every orderBy() call combined with a where() on a different field requires a composite index in Firestore. Without it, the SDK throws a FAILED_PRECONDITION error and includes a direct link in the error message to create the index in the Firebase Console with a single click.

  • Single-field indexes are created automatically by Firestore.
  • Composite indexes (multi-field) must be defined explicitly in firestore.indexes.json or via the Console.
  • Indexes are per-collection and are eventually consistent — they may take a few minutes to build after creation.
  • Deploy indexes with firebase deploy --only firestore:indexes.
Note: During development, run your query once to trigger the error, then click the link in the error message. For production, declare all indexes in firestore.indexes.json and commit them to version control so teammates and CI share the same index configuration.

Real-Time Pagination with StreamBuilder

For live-updating feeds, combine a Query with a StreamBuilder. Keep a reactive reference to the current query and update it when the user requests more data. Because each page is a separate stream snapshot, merging pages requires maintaining a local list that accumulates results across pages.

Summary

Firestore's querying surface — where(), orderBy(), limit(), and cursor-based pagination — covers the vast majority of real-world data-fetching needs. The key discipline is to plan your indexes up-front, always page with document snapshots rather than offsets, and respect the constraint that inequality filters must come first in the orderBy() chain. These patterns compose cleanly whether you are building a one-time Future fetch or a live-updating Stream.