Skip to content

Queries

Build type-safe reads, aggregations, streams, and real-time subscriptions with the fluent query builder.

Call repo.query() to get a FirestoreQueryBuilder<T, W>. It exposes a fluent, type-safe interface for composing filters, ordering, projections, aggregations, pagination, streaming, and real-time listeners. Chain the builder methods and then call a terminal method (get(), getOne(), exists(), count(), paginate(), and so on) to execute the query.

const results = await orderRepo
.query()
.where('status', '==', 'pending')
.where('total', '>', 100)
.where('createdAt', '>=', startOfMonth)
.orderBy('total', 'desc')
.limit(50)
.get();

The chainable builder methods are:

  • where(field, op, value) — add a filter clause.
  • select(...fields) — project only the named fields.
  • orderBy(field, dir = 'asc') — sort results (required before paginate()).
  • limit(n) — cap the number of documents returned.

Terminal methods that execute the query include get(), getOne(), exists(), count(), totalCount(), sum(), average(), distinctValues(), paginate(), offsetPaginate(), paginateWithCount(), stream(), onSnapshot(), update(), and delete(). There is no public .startAfter() chaining — cursor pagination is handled entirely through paginate(pageSize, cursor).

Performance note: Firestore charges per document read. Use limit() and pagination to control costs on large collections — see Performance for the full cost model.

Stack where() clauses to narrow results. All standard Firestore operators are supported, including in, array-contains, and range comparisons.

const results = await userRepo
.query()
.where('age', '>', 18)
.where('status', 'in', ['active', 'verified'])
.where('tags', 'array-contains', 'premium')
.get();

Chain orderBy() calls to sort by one or more fields. The direction defaults to 'asc'.

const sorted = await productRepo.query().orderBy('price', 'desc').orderBy('name', 'asc').get();

paginate(pageSize, cursor?) performs cursor-based pagination and returns { items, nextCursor, hasMore }. It requires at least one prior orderBy() call for a stable cursor and throws if pageSize is less than or equal to 0. Pass the previous page’s nextCursor to fetch the next page.

// Cursor-based pagination (recommended)
// orderBy() is required for stable cursor pagination
const { items, nextCursor, hasMore } = await userRepo
.query()
.orderBy('createdAt', 'desc')
.paginate(20);
// Next page
const nextPage = await userRepo.query().orderBy('createdAt', 'desc').paginate(20, nextCursor);

Use offsetPaginate(page, pageSize) for offset-based pagination. It is simpler but less efficient on large datasets, since Firestore must scan and discard the skipped documents.

// Offset pagination (less efficient for large datasets)
const page2 = await userRepo.query().orderBy('createdAt', 'desc').offsetPaginate(2, 20);

paginateWithCount(pageSize, cursor?) combines paginate() and count() in a single call, returning the same { items, nextCursor, hasMore } plus a total count of all matching documents. It performs an extra aggregation read.

const { items, nextCursor, hasMore, total } = await productRepo
.query()
.where('inStock', '==', true)
.orderBy('createdAt', 'desc')
.paginateWithCount(20, lastCursor);
console.log(`Showing ${items.length} of ${total} products`);

Aggregations run server-side and are billed at a reduced rate compared to reading every matching document.

// Sum a numeric field across matching documents
const totalRevenue = await orderRepo.query().where('status', '==', 'completed').sum('total');
// Average a numeric field across matching documents
const avgRating = await reviewRepo.query().where('productId', '==', 'prod-123').average('rating');
// Count matching documents
const activeCount = await userRepo.query().where('status', '==', 'active').count();
// Total collection count — ignores any accumulated where() clauses
const totalUsers = await userRepo.query().where('status', '==', 'active').totalCount();
// Existence check
const hasOrders = await orderRepo.query().where('userId', '==', 'user-123').exists();
// Distinct values for a field
const categories = await productRepo.query().distinctValues('category');

count() respects the query’s filters, whereas totalCount() counts the entire collection and ignores any where() clauses on the builder. sum(field) and average(field) operate on numeric fields, and distinctValues(field) returns the unique values for a field.

Use select() to project only the fields you need. This reduces payload size but does not reduce the document-read cost.

const userEmails = await userRepo
.query()
.where('subscribed', '==', true)
.select('email', 'name')
.get();

query().update(data) and query().delete() apply to every document matching the query and each return the matched count.

Note: Lifecycle hooks do not run for query().update() or query().delete(). If you need per-document hooks (see Lifecycle hooks), iterate and call the single-document methods instead.

// Update all matching documents; returns the number of documents matched
const updatedCount = await orderRepo
.query()
.where('status', '==', 'pending')
.update({ status: 'processing' });
// Delete all matching documents; returns the number of documents matched
const deletedCount = await userRepo.query().where('lastLogin', '<', oneYearAgo).delete();
// Delete matching documents with multiple filters
await orderRepo
.query()
.where('status', '==', 'cancelled')
.where('createdAt', '<', sixMonthsAgo)
.delete();

stream() returns an async generator that yields matching documents one at a time, avoiding the memory cost of loading an entire result set at once.

// Stream all users without loading them into memory
for await (const user of userRepo.query().stream()) {
await sendEmail(user.email);
console.log(`Processed user ${user.id}`);
}
// Stream with filters
for await (const order of orderRepo.query().where('status', '==', 'pending').stream()) {
await processOrder(order);
}

Performance cost: Streaming still reads every matching document, so you are charged for every document read. Use appropriate filters and limits.

onSnapshot(callback, onError?) subscribes to live query results. It resolves to an unsubscribe function — call it to stop listening. The callback receives the current set of matching documents on every change.

// Subscribe to query results
const unsubscribe = await orderRepo
.query()
.where('status', '==', 'active')
.onSnapshot(
orders => {
console.log(`Active orders: ${orders.length}`);
updateDashboard(orders);
},
error => {
console.error('Snapshot error:', error);
},
);
// Stop listening when done
unsubscribe();

Cost warning: Real-time listeners charge you for every document that matches your query, plus additional reads when documents change. Use narrow filters and consider polling for less critical data.