Query Firestore documents using the modular SDK v9+ by building query objects with collection(), where(), orderBy(), and limit(). Chain multiple where() clauses for compound queries, use getDocs() for one-time fetches or onSnapshot() for real-time results. Firestore requires a composite index for compound queries with range filters on different fields — the error message includes a direct link to create the needed index.
Querying Documents in Cloud Firestore
Firestore stores data in documents organized into collections. To retrieve specific documents, you build query objects that describe what data you want. This tutorial covers single-field queries, compound queries with multiple conditions, ordering and limiting results, and subscribing to real-time updates. You will also learn how Firestore indexes work and what to do when the SDK tells you a composite index is required.
Prerequisites
- A Firebase project with Firestore enabled
- Firebase JS SDK v9+ installed (npm install firebase)
- A Firestore collection with documents to query
- Basic understanding of JavaScript async/await
Step-by-step guide
Initialize Firestore and build a simple query
Initialize Firestore and build a simple query
Import the required functions from the firebase/firestore module and initialize your Firestore instance. Then build a simple query using collection() to target a collection and where() to filter by a single field. The query() function composes these constraints into a query object. Call getDocs() to execute the query and iterate over the results.
1import { initializeApp } from 'firebase/app'2import { getFirestore, collection, query, where, getDocs } from 'firebase/firestore'34const app = initializeApp({5 apiKey: 'YOUR_API_KEY',6 authDomain: 'YOUR_PROJECT.firebaseapp.com',7 projectId: 'YOUR_PROJECT_ID'8})910const db = getFirestore(app)1112async function getElectronics() {13 const q = query(14 collection(db, 'products'),15 where('category', '==', 'electronics')16 )1718 const snapshot = await getDocs(q)19 snapshot.forEach((doc) => {20 console.log(doc.id, doc.data())21 })22}Expected result: The console logs each document ID and its data for all products in the electronics category.
Build compound queries with multiple where() clauses
Build compound queries with multiple where() clauses
Chain multiple where() clauses to filter on several fields. Firestore allows multiple equality filters freely. When you combine an equality filter with a range filter (like < or >), both fields can differ. However, range filters on two different fields require a composite index. If the index does not exist, Firestore throws an error with a clickable link to create it in the Firebase Console.
1import { query, collection, where, orderBy, limit } from 'firebase/firestore'23// Equality + range on different fields (needs composite index)4const q = query(5 collection(db, 'products'),6 where('category', '==', 'electronics'),7 where('price', '<', 500),8 orderBy('price'),9 limit(25)10)Expected result: The query returns up to 25 electronics products priced under 500, sorted by price ascending.
Order and limit query results
Order and limit query results
Use orderBy() to sort results by a field and limit() to cap the number of returned documents. You can chain multiple orderBy() calls for secondary sorting. When combining orderBy() with where() range filters, the first orderBy() field must match the where() range field. Use limitToLast() to get the last N results instead of the first N.
1import { query, collection, orderBy, limit, limitToLast } from 'firebase/firestore'23// Get 10 newest products4const newestQuery = query(5 collection(db, 'products'),6 orderBy('createdAt', 'desc'),7 limit(10)8)910// Get 5 oldest products (using limitToLast with ascending order)11const oldestQuery = query(12 collection(db, 'products'),13 orderBy('createdAt', 'asc'),14 limitToLast(5)15)Expected result: The first query returns the 10 most recently created products. The second returns the 5 oldest.
Subscribe to real-time query results with onSnapshot()
Subscribe to real-time query results with onSnapshot()
Instead of a one-time fetch with getDocs(), use onSnapshot() to listen for real-time updates. The callback fires immediately with the current results, then again whenever a matching document is added, modified, or removed. The snapshot includes a docChanges() method that tells you exactly what changed since the last callback. Always store the unsubscribe function and call it when the listener is no longer needed.
1import { query, collection, where, orderBy, onSnapshot } from 'firebase/firestore'23const q = query(4 collection(db, 'products'),5 where('inStock', '==', true),6 orderBy('price', 'asc')7)89const unsubscribe = onSnapshot(q, (snapshot) => {10 const products = snapshot.docs.map((doc) => ({11 id: doc.id,12 ...doc.data()13 }))14 console.log('Current in-stock products:', products)1516 // Check what changed17 snapshot.docChanges().forEach((change) => {18 if (change.type === 'added') console.log('New:', change.doc.data())19 if (change.type === 'modified') console.log('Updated:', change.doc.data())20 if (change.type === 'removed') console.log('Removed:', change.doc.data())21 })22})2324// Call when done listening25// unsubscribe()Expected result: The callback logs all in-stock products immediately, then logs changes in real time as documents are added, modified, or removed.
Use array-contains and in operators
Use array-contains and in operators
The array-contains operator checks if an array field contains a specific value. The in operator checks if a field matches any value in a provided array (up to 30 values). You can combine array-contains with equality filters, but you cannot use array-contains and array-contains-any in the same query, and you cannot use more than one in or not-in filter per query.
1import { query, collection, where } from 'firebase/firestore'23// Find products tagged with 'sale'4const tagQuery = query(5 collection(db, 'products'),6 where('tags', 'array-contains', 'sale')7)89// Find products in specific categories10const multiCategoryQuery = query(11 collection(db, 'products'),12 where('category', 'in', ['electronics', 'clothing', 'books'])13)1415// Combine array-contains with equality16const combinedQuery = query(17 collection(db, 'products'),18 where('tags', 'array-contains', 'sale'),19 where('category', '==', 'electronics')20)Expected result: Each query returns only the documents matching the specified filter conditions.
Handle query errors and empty results
Handle query errors and empty results
Always wrap query execution in try/catch to handle errors such as missing indexes, permission denials, or network issues. Check snapshot.empty before processing results. The most common error is 'Missing or insufficient permissions' which means your Firestore security rules do not allow the query. The second most common is 'The query requires an index' which means you need to create a composite index.
1async function safeQuery() {2 const q = query(3 collection(db, 'products'),4 where('category', '==', 'electronics'),5 where('price', '>', 100)6 )78 try {9 const snapshot = await getDocs(q)1011 if (snapshot.empty) {12 console.log('No matching documents found.')13 return []14 }1516 return snapshot.docs.map((doc) => ({17 id: doc.id,18 ...doc.data()19 }))20 } catch (error) {21 if (error.code === 'permission-denied') {22 console.error('Check your Firestore security rules.')23 } else if (error.message.includes('requires an index')) {24 console.error('Create the composite index using the link in the full error.')25 } else {26 console.error('Query failed:', error)27 }28 return []29 }30}Expected result: The function returns an array of matching products or an empty array, with clear error messages for common failure modes.
Complete working example
1import { initializeApp } from 'firebase/app'2import {3 getFirestore,4 collection,5 query,6 where,7 orderBy,8 limit,9 getDocs,10 onSnapshot,11 QuerySnapshot,12 DocumentData13} from 'firebase/firestore'1415const app = initializeApp({16 apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY!,17 authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN!,18 projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID!19})2021const db = getFirestore(app)2223// One-time fetch with compound query24async function fetchProducts(25 category: string,26 maxPrice: number,27 pageSize: number = 2528) {29 const q = query(30 collection(db, 'products'),31 where('category', '==', category),32 where('price', '<', maxPrice),33 orderBy('price', 'asc'),34 limit(pageSize)35 )3637 try {38 const snapshot = await getDocs(q)39 if (snapshot.empty) return []4041 return snapshot.docs.map((doc) => ({42 id: doc.id,43 ...doc.data()44 }))45 } catch (error: any) {46 console.error('Query failed:', error.message)47 return []48 }49}5051// Real-time listener52function subscribeToInStockProducts(53 callback: (products: DocumentData[]) => void54) {55 const q = query(56 collection(db, 'products'),57 where('inStock', '==', true),58 orderBy('createdAt', 'desc'),59 limit(50)60 )6162 const unsubscribe = onSnapshot(63 q,64 (snapshot: QuerySnapshot) => {65 const products = snapshot.docs.map((doc) => ({66 id: doc.id,67 ...doc.data()68 }))69 callback(products)70 },71 (error) => {72 console.error('Listener error:', error.message)73 }74 )7576 return unsubscribe77}7879export { fetchProducts, subscribeToInStockProducts }Common mistakes when querying Documents in Firestore
Why it's a problem: Using range filters on two different fields without creating a composite index
How to avoid: Click the link in the Firestore error message to create the required composite index automatically in the Firebase Console. Alternatively, define indexes in firestore.indexes.json and deploy with the CLI.
Why it's a problem: Combining array-contains with array-contains-any or using two in filters in the same query
How to avoid: Firestore allows only one array-contains or array-contains-any and one in or not-in per query. Restructure your data model or run separate queries and merge results client-side.
Why it's a problem: Not calling the unsubscribe function returned by onSnapshot(), causing memory leaks
How to avoid: Store the return value of onSnapshot() and call it when the component unmounts or the listener is no longer needed. In React, place it in the useEffect cleanup function.
Why it's a problem: Expecting security rules to filter results — rules reject entire queries, not individual documents
How to avoid: Make sure your query constraints match what your security rules allow. If rules require request.auth != null, ensure the user is authenticated before running the query.
Best practices
- Always include orderBy() when using limit() to ensure consistent and predictable result ordering
- Use getDocs() for one-time data fetches and onSnapshot() only when you need real-time updates to reduce read costs
- Wrap query execution in try/catch to handle permission errors and missing index errors gracefully
- Pre-create composite indexes in firestore.indexes.json and deploy them with the CLI to avoid runtime surprises
- Use snapshot.empty to check for no results before iterating over documents
- Combine queries with Firestore pagination (startAfter/limit) for large result sets instead of fetching everything at once
- Keep where() and orderBy() field order consistent — the first orderBy field must match the range filter field
Still stuck?
Copy one of these prompts to get a personalized, step-by-step explanation.
I have a Firestore collection called 'products' with fields: name, category, price, tags (array), inStock (boolean), and createdAt (timestamp). Show me how to write compound queries using the Firebase modular SDK v9, including filtering by category and price range, querying array fields with array-contains, ordering by price, and paginating with startAfter and limit.
Write a Firestore query helper module using Firebase modular SDK v9+ that provides functions for: single-field queries with where(), compound queries with multiple filters, real-time subscriptions with onSnapshot(), and cursor-based pagination with startAfter(). Use TypeScript and include error handling for missing indexes and permission denied errors.
Frequently asked questions
How many where() clauses can I chain in a single Firestore query?
You can chain multiple where() clauses with equality (==) filters on different fields without limits. However, you can only use one array-contains or array-contains-any filter and one in or not-in filter per query. Range filters on multiple different fields require composite indexes.
What does 'The query requires an index' mean?
Firestore automatically indexes single fields but requires manually created composite indexes for queries combining range filters or ordering on different fields. The error message includes a direct link to the Firebase Console that pre-fills the index configuration — just click it and confirm.
Do Firestore queries count as reads for billing?
Yes. Each document returned by a query counts as one read. A query that returns 50 documents counts as 50 reads. If a query returns no results, it still counts as one read. Use limit() and pagination to control costs.
Can I query across multiple collections with the same name?
Yes. Use collectionGroup() instead of collection() to query all subcollections sharing the same ID. Collection group queries require a collection-group scope index, which you can create in the Firebase Console.
How do I sort by one field and filter by another?
Combine where() and orderBy() in the same query. If you use a range filter (like < or >) on one field and orderBy() on a different field, you need a composite index. Firestore requires the first orderBy() field to match the range filter field.
Can RapidDev help me build complex Firestore query logic for my application?
Yes. RapidDev can design efficient Firestore query patterns, set up composite indexes, implement pagination, and optimize your data model for your specific use case.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation