Build a Product Catalog with Firestore
Design a NoSQL product catalog with real-time updates and transactions.
Introduction
30 Second Summary
Every online store you browse has a product catalog behind it, organizing thousands of items so you can filter by category, sort by price, and see live stock updates.
In this project, you will build an e-commerce product catalog backed by Firestore, learning how to design NoSQL data models, work within Firestore's query constraints, and use transactions and security rules to protect your data.
What You'll Build
A product catalog web app backed by Firestore that demonstrates real-world NoSQL data modeling patterns, compound queries, real-time updates, and transactional inventory management.
By the end of this project, you'll have:
- A Firestore data model with root-level collections, denormalized fields, and subcollections designed around real access patterns.
- Hands-on experience with compound queries, composite indexes, and real-time listeners that push live updates to the browser.
- A transactional inventory system and security rules that control read and write access at the database level.
- A secret mission to build a "Related Products" feature using collection group queries that search across every review subcollection in the database.
Are there any prerequisites?
You should have basic JavaScript and HTML knowledge. Familiarity with database concepts is helpful. If you have experience with Postgres or Aurora, this project will show you how NoSQL thinking differs from the relational approach you already know.
Set Up Firebase and Your Project
Before you can model data or write queries, you need a Firestore database to work with. Firebase is Google's app development platform, and Firestore is its NoSQL document database. In this step, you'll create a Firebase project, spin up a Firestore database, and wire up a local project that can talk to it.
In This Step, Get Ready To:
- Create a Firebase project and Firestore database in the Firebase Console.
- Set up a local Node.js project with the Firebase Web SDK.
- Initialize Firestore in your app code and confirm the connection.
Create a Firebase Project
A Firebase project is the container for all your Firebase services, including Firestore, authentication, and hosting. You'll create one through the Firebase Console.
- Open the Firebase Console and sign in with your Google account.
- Click Add project.
- Enter a project name like product-catalog.
- Disable Google Analytics when prompted. You won't need it for this project.
- Click Create project and wait for Firebase to finish provisioning.
Once the project is ready, Firebase takes you to the project dashboard.
Create a Firestore Database
Your Firebase project doesn't come with a Firestore database by default. You need to create one and choose where your data will be stored geographically.
- In the left sidebar, expand Build and select Firestore Database.
- Click Create database.
- Choose a location near you. For North America, us-central1 is a good default.
- Select Start in test mode.
- Click Create.
What is test mode?
Test mode allows anyone to read and write your data for 30 days. After that, all access is blocked. This is fine for development. You'll replace these open rules with proper security rules in Step 7.
You should now see an empty Firestore database in the console with a message inviting you to start a collection.
Set Up the Local Project
Now you need a local project that can connect to your Firestore database. You'll use the Firebase Web SDK, which provides the modular imports for initializing Firebase and interacting with Firestore from JavaScript.
- Create a new project folder and open it in VS Code.
- Open the terminal and initialize a Node.js project.
npm init -y
- Install the Firebase SDK.
npm install firebase@12.12.0
- Create two files in your project root: index.html and app.js.
Next, you need your Firebase configuration object. This contains the project-specific keys that tell the SDK which Firebase project to connect to.
- In the Firebase Console, click the gear icon next to Project Overview and select Project settings.
- Scroll down to Your apps and click the web icon (</>) to register a new web app.
- Give the app a nickname like product-catalog-web and click Register app.
- Copy the Firebase configuration object that appears.
Now paste that config into app.js and initialize Firestore.
import { initializeApp } from "firebase/app";
import { getFirestore } from "firebase/firestore";
// Replace this object with the config from your Firebase Console
const firebaseConfig = {
apiKey: "your-api-key-here",
authDomain: "your-project-id.firebaseapp.com",
projectId: "your-project-id",
storageBucket: "your-project-id.firebasestorage.app",
messagingSenderId: "123456789",
appId: "1:123456789:web:abcdef123456"
};
// Initialize Firebase and get a reference to the Firestore database
const app = initializeApp(firebaseConfig);
const db = getFirestore(app);
console.log("Firestore initialized:", db.type);
What does this code do?
The initializeApp function connects your code to your specific Firebase project using the config object. The getFirestore function returns a reference to the Firestore database, which you'll use for every read and write operation going forward.
Pro tip: config keys are not secrets
The Firebase config object contains identifiers, not secrets. These keys tell the SDK which project to connect to, but they don't grant any permissions on their own. Security rules (which you'll set up in Step 7) control who can actually read and write data.
Now set up a minimal index.html to load your app.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Product Catalog</title>
</head>
<body>
<h1>Product Catalog</h1>
<div id="products"></div>
<!-- type="module" enables ES module imports in the browser -->
<script type="module" src="app.js"></script>
</body>
</html>
Why type="module"?
The Firebase Web SDK uses ES module import statements. Adding type="module" to the script tag tells the browser to treat app.js as an ES module so those imports work.
To serve the app locally, you need a simple development server. The Firebase SDK's module imports won't work if you just open the HTML file directly.
- Install a lightweight dev server.
npx vite
What does this command do?
Running npx vite starts a local development server that handles ES module resolution. It serves your index.html and resolves the Firebase SDK imports from node_modules automatically.
- Open the URL shown in the terminal (usually http://localhost:5173) in your browser.
- Open the browser's developer console (press Cmd+Option+J on macOS or Ctrl+Shift+J on Windows).
You should see the "Product Catalog" heading on the page and a Firestore initialized: firestore message in the console. This confirms your app is connected to your Firestore database.
I see the console message
Your Firebase project and Firestore database are wired up correctly. You're ready to start designing your data model.
I see an error
Check these common issues:
- Make sure you copied the full Firebase config object from the console, including all fields.
- Verify that your script tag in index.html has type="module".
- Confirm you're running npx vite from the same folder that contains your package.json.
Still stuck?
Copy the exact error message from your browser console and ask for help debugging it.
Your environment is ready and connected to Firestore. Next up, you'll design the data model for your product catalog and learn why your Postgres instincts need some rewiring for NoSQL.
Design the Data Model
Your Firebase project is ready and your local environment is wired up. Now comes the most important decision in any Firestore project: how you structure your data.
If you come from a Postgres or relational database background, your instinct is to normalize everything into tidy tables with foreign keys and JOINs. Firestore has none of those things. In this step, you will design a data model that works with Firestore's strengths instead of fighting against them.
In This Step, Get Ready To:
- Map out the relational schema you would build in Postgres and understand why it does not work in Firestore.
- Design a Firestore data model with root-level collections, embedded fields, and subcollections.
- Understand the denormalization tradeoffs you are making and why they are worth it.
Map Out the Relational Schema
Before jumping into Firestore's way of doing things, let's think about how you would model this product catalog in Postgres. This contrast makes the Firestore design decisions much clearer.
In a relational database, you would create four tables:
- A products table with columns for name, price, description, and stock.
- A categories table with a category name and ID.
- A reviews table with a foreign key pointing back to the product.
- A product_categories join table to handle the many-to-many relationship between products and categories.
To display a product page, you would write a SQL query that JOINs the product to its category and aggregates its reviews. Clean, normalized, no duplicated data.
This approach does not work in Firestore. There are no JOINs, no foreign keys, and no ad-hoc queries across arbitrary field combinations. Firestore is designed to return results in time proportional to the result set size, not the total dataset size. That guarantee comes with a constraint: you must model your data around your access patterns, not around normalization.
Why model around access patterns?
In Postgres, you normalize first and query however you want later. The query planner figures out how to get your data efficiently.
In Firestore, every query must be backed by an index, and there are no JOINs. So you start by asking: "How will my app read this data?" Then you structure documents so that each read fetches exactly what it needs in a single query.
Design the Firestore Data Model
Your product catalog has three main access patterns: browsing products (with filtering and sorting), viewing category metadata, and reading reviews for a specific product. Each access pattern drives a design decision.
Create a new file called data-model.js to document your schema. This file is not code you will run. It is a reference for the structure you will build.
// DATA MODEL REFERENCE
// This file documents the Firestore schema for the product catalog.
// It is not executable code.
// ─── Root-level collection: products ───
// Each document represents one product.
// Access pattern: browse, filter by category, sort by price, filter by tags.
const productDocument = {
name: "Wireless Headphones",
price: 79.99,
description: "Noise-cancelling over-ear headphones",
stock: 25,
categoryId: "electronics",
categoryName: "Electronics", // Denormalized from categories collection
tags: ["wireless", "bluetooth", "audio"], // Array field for array-contains queries
rating: 4.5,
reviewCount: 12,
createdAt: "2025-01-15T10:00:00Z"
};
// ─── Root-level collection: categories ───
// Each document holds category metadata.
// Access pattern: list all categories for navigation menus.
const categoryDocument = {
name: "Electronics",
description: "Gadgets, devices, and accessories",
productCount: 5
};
// ─── Subcollection: products/{productId}/reviews ───
// Each document is one review, nested under its parent product.
// Access pattern: fetch reviews for a specific product.
const reviewDocument = {
userId: "user_abc123",
userName: "Jane Doe",
rating: 5,
text: "Best headphones I have ever owned.",
createdAt: "2025-02-01T14:30:00Z"
};
Let's break down the three key design decisions in this model.
Root-level products collection. Products are the primary thing users interact with. They browse, filter, and sort products constantly. Placing them at the root level gives you full query capability on every field.
Denormalized category info. Notice that categoryName is embedded directly in each product document. In Postgres, you would never do this. You would store a category_id foreign key and JOIN to get the name. In Firestore, there are no JOINs, so you embed the data you need at read time. This is called denormalization.
Subcollection for reviews. Reviews grow unboundedly over time. If you embedded them as an array inside the product document, you would eventually hit Firestore's 1 MiB document size limit. A subcollection keeps the product document lightweight and gives you full query capabilities on the reviews themselves.
The tags array field is worth calling out too. It enables array-contains queries, which is Firestore's way of doing "contains" searches. This is more flexible than a join table in Postgres. The limitation is that you can only use one array-contains clause per query.
Why not just use auto-generated IDs for everything?
You might notice that the category documents use readable IDs like electronics instead of auto-generated ones. This is fine for small, stable collections like categories where you know the IDs ahead of time.
For products, use Firestore's auto-generated IDs. They use a scatter algorithm that avoids write hotspots. Monotonically increasing IDs (like Product1, Product2, Product3) concentrate writes on the same storage node and can cause performance bottlenecks.
Understand the Denormalization Tradeoffs
Denormalization is not free. Embedding categoryName in every product document means that if a category name changes, you must update every single product in that category. This is called a fan-out write.
In Postgres, renaming a category is one UPDATE statement on one row. In Firestore, it could mean updating hundreds of product documents. For a product catalog where category names rarely change, this is the right tradeoff. Reads are fast and cheap because you never need a second query to look up the category name.
Here is a summary of the tradeoffs you are making:
- Embedded categoryName: reads are one query instead of two, but category renames require fan-out writes across all products in that category.
- Subcollection for reviews: the product document stays small and fast to read, but fetching a product with its reviews requires two separate reads (one for the product, one for the subcollection).
- Array field for tags: enables flexible filtering with array-contains, but you can only filter on one tag per query.
- Denormalized rating and reviewCount: you can sort and filter products by rating without querying the reviews subcollection, but every new review requires updating these fields on the parent product.
Why denormalize instead of normalizing like Postgres?
In Postgres, the query planner handles JOINs efficiently, so normalization (no duplicated data) is the default. In Firestore, there is no query planner and no JOINs. Every extra read costs money and adds latency.
Denormalization trades write complexity for read simplicity. In an e-commerce catalog, reads vastly outnumber writes. Users browse products thousands of times for every category rename. Optimizing for the common case (reads) is the right call.
Your data model is designed and documented. Next up, you will bring this model to life by seeding the database with real product data using batched writes.
Seed the Database with Batched Writes
You have a data model designed around your access patterns. Now you need actual data in the database to work with. Manually creating documents in the Firebase Console would be tedious and error-prone for 10+ documents across multiple collections.
Firestore offers batched writes that let you group multiple write operations into a single atomic commit. Every operation in the batch either succeeds together or fails together. In this step, you will write a seed script that populates your entire catalog in one shot.
In This Step, Get Ready To:
- Write a seed script that uses batched writes to populate categories and products atomically.
- Add review documents to subcollections under specific products.
- Verify the seeded data in the Firebase Console.
Write the Seed Script with Batched Writes
A batched write groups up to 500 write operations into a single atomic unit. Each operation in the batch counts as one write against your free tier quota (20,000 writes per day), but they all commit or fail as one. This is perfect for seeding because you do not want a half-populated database if something goes wrong.
- Create a new file called seed.js in your project root.
- Add the following code to seed your categories and products:
import { initializeApp } from "firebase/app";
import { getFirestore, writeBatch, doc, collection, Timestamp } from "firebase/firestore";
// Replace with your Firebase config from Project Settings
const firebaseConfig = {
apiKey: "your-api-key-here",
authDomain: "your-project.firebaseapp.com",
projectId: "your-project-id",
storageBucket: "your-project.firebasestorage.app",
messagingSenderId: "123456789",
appId: "your-app-id"
};
const app = initializeApp(firebaseConfig);
const db = getFirestore(app);
async function seedDatabase() {
const batch = writeBatch(db);
// --- Categories ---
const categories = [
{ id: "electronics", name: "Electronics", description: "Gadgets, audio, and accessories" },
{ id: "clothing", name: "Clothing", description: "Apparel and fashion" },
{ id: "home", name: "Home Goods", description: "Furniture, decor, and kitchen" }
];
categories.forEach((cat) => {
const catRef = doc(db, "categories", cat.id);
batch.set(catRef, {
name: cat.name,
description: cat.description,
createdAt: Timestamp.now()
});
});
// --- Products ---
const products = [
{
id: "prod-001", name: "Wireless Headphones", price: 79.99, stock: 25,
categoryId: "electronics", categoryName: "Electronics",
tags: ["wireless", "bluetooth", "audio"], rating: 4.5, reviewCount: 3,
description: "Noise-canceling over-ear headphones with 30-hour battery life."
},
{
id: "prod-002", name: "USB-C Hub", price: 34.99, stock: 50,
categoryId: "electronics", categoryName: "Electronics",
tags: ["usb-c", "hub", "accessories"], rating: 4.2, reviewCount: 1,
description: "7-in-1 hub with HDMI, USB-A, and SD card reader."
},
{
id: "prod-003", name: "Mechanical Keyboard", price: 129.99, stock: 15,
categoryId: "electronics", categoryName: "Electronics",
tags: ["keyboard", "mechanical", "gaming"], rating: 4.8, reviewCount: 2,
description: "Hot-swappable switches with RGB backlighting."
},
{
id: "prod-004", name: "Running Shoes", price: 95.00, stock: 40,
categoryId: "clothing", categoryName: "Clothing",
tags: ["shoes", "running", "athletic"], rating: 4.3, reviewCount: 2,
description: "Lightweight mesh upper with responsive cushioning."
},
{
id: "prod-005", name: "Denim Jacket", price: 65.00, stock: 20,
categoryId: "clothing", categoryName: "Clothing",
tags: ["jacket", "denim", "casual"], rating: 4.0, reviewCount: 1,
description: "Classic fit with brass button closure."
},
{
id: "prod-006", name: "Wool Beanie", price: 18.99, stock: 100,
categoryId: "clothing", categoryName: "Clothing",
tags: ["hat", "winter", "wool"], rating: 4.6, reviewCount: 0,
description: "Merino wool blend, one size fits all."
},
{
id: "prod-007", name: "Ceramic Planter", price: 24.99, stock: 35,
categoryId: "home", categoryName: "Home Goods",
tags: ["planter", "ceramic", "decor"], rating: 4.1, reviewCount: 1,
description: "Matte finish with drainage hole and saucer."
},
{
id: "prod-008", name: "Cast Iron Skillet", price: 44.99, stock: 18,
categoryId: "home", categoryName: "Home Goods",
tags: ["kitchen", "cast-iron", "cookware"], rating: 4.9, reviewCount: 2,
description: "Pre-seasoned 12-inch skillet, oven safe to 500F."
},
{
id: "prod-009", name: "Linen Throw Blanket", price: 55.00, stock: 12,
categoryId: "home", categoryName: "Home Goods",
tags: ["blanket", "linen", "decor"], rating: 4.4, reviewCount: 1,
description: "Stonewashed French linen in natural beige."
},
{
id: "prod-010", name: "Portable Speaker", price: 49.99, stock: 60,
categoryId: "electronics", categoryName: "Electronics",
tags: ["wireless", "bluetooth", "audio", "portable"], rating: 4.3, reviewCount: 1,
description: "Waterproof Bluetooth speaker with 12-hour playtime."
}
];
products.forEach((prod) => {
const prodRef = doc(db, "products", prod.id);
batch.set(prodRef, {
name: prod.name,
price: prod.price,
stock: prod.stock,
categoryId: prod.categoryId,
categoryName: prod.categoryName,
tags: prod.tags,
rating: prod.rating,
reviewCount: prod.reviewCount,
description: prod.description,
createdAt: Timestamp.now()
});
});
// Commit all 13 documents in one atomic operation
await batch.commit();
console.log("Batch committed: 3 categories + 10 products");
}
seedDatabase().catch(console.error);
What does this script do?
The script creates a single writeBatch and adds 13 set operations to it: 3 category documents and 10 product documents. When batch.commit() is called, all 13 writes happen atomically. If any single write fails, none of them are applied.
Notice how each product document has categoryName embedded directly. This is the denormalization pattern from Step 2 in action. The tags array field will let you run array-contains queries in the next step.
Why use explicit document IDs instead of auto-generated ones?
For seed data, explicit IDs like prod-001 make it easy to reference specific products when adding reviews in the next substep. In production, Firestore's auto-generated IDs are preferred because they use a scatter algorithm that avoids write hotspots. Monotonically increasing IDs (like Product1, Product2) can cap writes at around 500 per second per collection.
Add Reviews as a Subcollection
Reviews live in a subcollection under each product document. You cannot include subcollection writes in the same batch as the parent documents because subcollection paths depend on the parent document already existing in your reference structure. You will add reviews in a separate step after the main batch commits.
- Add the following function to seed.js, right after the seedDatabase function:
import { setDoc } from "firebase/firestore";
async function seedReviews() {
// Reviews for Wireless Headphones (prod-001)
const headphoneReviews = [
{
userId: "user-alice", userName: "Alice", rating: 5,
text: "Best noise canceling I have used at this price point.",
createdAt: Timestamp.now()
},
{
userId: "user-bob", userName: "Bob", rating: 4,
text: "Great sound quality. Ear cushions could be softer.",
createdAt: Timestamp.now()
},
{
userId: "user-carol", userName: "Carol", rating: 4,
text: "Battery life is impressive. Bluetooth range is decent.",
createdAt: Timestamp.now()
}
];
for (const review of headphoneReviews) {
// Create a reference to a new doc in the reviews subcollection
const reviewRef = doc(collection(doc(db, "products", "prod-001"), "reviews"));
await setDoc(reviewRef, review);
}
console.log("Added 3 reviews to prod-001 (Wireless Headphones)");
// Reviews for Cast Iron Skillet (prod-008)
const skilletReviews = [
{
userId: "user-alice", userName: "Alice", rating: 5,
text: "Perfectly seasoned out of the box. Sears beautifully.",
createdAt: Timestamp.now()
},
{
userId: "user-dave", userName: "Dave", rating: 5,
text: "Heavy but worth it. Nothing sticks.",
createdAt: Timestamp.now()
}
];
for (const review of skilletReviews) {
const reviewRef = doc(collection(doc(db, "products", "prod-008"), "reviews"));
await setDoc(reviewRef, review);
}
console.log("Added 2 reviews to prod-008 (Cast Iron Skillet)");
// Reviews for Running Shoes (prod-004)
const shoeReviews = [
{
userId: "user-bob", userName: "Bob", rating: 4,
text: "Comfortable for long runs. Sizing runs slightly small.",
createdAt: Timestamp.now()
},
{
userId: "user-carol", userName: "Carol", rating: 5,
text: "My new favorite running shoe. Super lightweight.",
createdAt: Timestamp.now()
}
];
for (const review of shoeReviews) {
const reviewRef = doc(collection(doc(db, "products", "prod-004"), "reviews"));
await setDoc(reviewRef, review);
}
console.log("Added 2 reviews to prod-004 (Running Shoes)");
}
seedReviews().catch(console.error);
What does this review seeding code do?
The path collection(doc(db, "products", "prod-001"), "reviews") creates a reference to the reviews subcollection under the product document with ID prod-001. Wrapping that in doc() without a second argument generates an auto-generated ID for each review document.
Notice that user user-alice reviews both headphones and the skillet. This shared reviewer is important for the Secret Mission later, where you will query across all review subcollections to find related products.
- Update the bottom of seed.js to call both functions in sequence:
// Run both seed functions in order
seedDatabase()
.then(() => seedReviews())
.then(() => console.log("Seeding complete!"))
.catch(console.error);
Pro tip: add type module to package.json
Since the seed script uses ES module import syntax, you need to tell Node.js to treat .js files as ES modules. Open package.json and add "type": "module" at the top level.
Run the Seed Script and Verify in the Console
- Open your terminal and run the seed script:
node seed.js
I see "Seeding complete!"
Your database is populated. Time to verify the data in the Firebase Console.
I see an error
Having trouble with the seed script?
- If you see a SyntaxError about import statements, make sure you added "type": "module" to your package.json.
- If you see a permissions error, confirm your Firestore database is still in test mode. Test mode allows open read and write access for 30 days.
- If you see a "module not found" error for firebase, make sure you ran npm install firebase@12.12.0 in the same directory.
- Double-check that your Firebase config values in seed.js match the ones from your Firebase Console under Project Settings > General > Your apps.
Still stuck? Help me debug my seed script error.
Now verify the data visually in the Firebase Console.
- Open the Firebase Console at console.firebase.google.com.
- Select your project, then expand Build in the left panel and click Firestore Database.
- Click on the products collection in the left column. You should see 10 product documents listed.
- Click on prod-001 to expand it. You should see all the fields (name, price, tags, categoryName, etc.) in the middle column.
- Look for the reviews subcollection listed below the document fields. Click into it to see the review documents nested under this product.
- Click on the categories collection to confirm all 3 categories are present.
The Firebase Console displays the document/collection/subcollection hierarchy visually. Collections appear in the left column, documents in the middle, and fields on the right. Subcollections appear as nested links under their parent document. This three-panel layout maps directly to how Firestore organizes data internally.
What is the document/collection/subcollection hierarchy?
In Firestore, data is organized as documents inside collections. A document can also contain subcollections, which hold their own documents. The path to a review looks like products/prod-001/reviews/abc123. This alternating collection/document/collection pattern can nest up to 100 levels deep.
Your database is loaded with realistic catalog data across three collections. Next up, you will query this data using Firestore's compound queries and discover firsthand where its query model differs from SQL.
Query Patterns and Limitations
Your database is seeded and ready to query. In a relational database like Postgres, you can write almost any SQL query and the query planner figures out how to execute it efficiently. Firestore works differently. Every query must be backed by an index, and certain query combinations require you to explicitly create composite indexes before they will run. In this step, you will build queries, hit real limitations, and learn how Firestore guarantees fast performance at any scale.
In This Step, Get Ready To:
- Build simple and compound queries using equality filters and array-contains.
- Trigger a composite index requirement and create the index to fix it.
- Run a multi-field inequality filter and understand index ordering for performance.
Build Simple and Compound Queries
Firestore queries always scan an index, not the raw documents. For single-field filters, Firestore automatically creates and maintains indexes. This means simple queries work out of the box with no extra setup.
- Open app.js and add a function that queries all products in a specific category.
import { collection, query, where, getDocs } from "firebase/firestore";
// Query products in the "electronics" category
async function getElectronics() {
const productsRef = collection(db, "products");
const q = query(productsRef, where("categoryId", "==", "electronics"));
const snapshot = await getDocs(q);
snapshot.forEach((doc) => {
console.log(doc.id, doc.data());
});
}
getElectronics();
What does this query do?
The where("categoryId", "==", "electronics") clause filters documents in the products collection to only return those where categoryId matches exactly. Firestore uses an automatically created single-field index to serve this query.
- Add a second query that filters products by tag using array-contains.
// Query products that have the "wireless" tag
async function getWirelessProducts() {
const productsRef = collection(db, "products");
const q = query(productsRef, where("tags", "array-contains", "wireless"));
const snapshot = await getDocs(q);
snapshot.forEach((doc) => {
console.log(doc.id, doc.data());
});
}
getWirelessProducts();
What is array-contains?
The array-contains operator checks if an array field contains a specific value. This is Firestore's equivalent of searching within a list. In Postgres, you would use a join table for tags. In Firestore, the array lives directly on the document.
One important limitation: you can only use one array-contains clause per disjunction group in a query.
- Now combine both filters into a single compound query.
// Compound query: electronics category AND wireless tag
async function getWirelessElectronics() {
const productsRef = collection(db, "products");
const q = query(
productsRef,
where("categoryId", "==", "electronics"),
where("tags", "array-contains", "wireless")
);
const snapshot = await getDocs(q);
snapshot.forEach((doc) => {
console.log(doc.id, doc.data());
});
}
getWirelessElectronics();
Why does this compound query work without extra setup?
Combining an equality filter with array-contains works automatically because Firestore can merge the results from two single-field indexes. You only need a composite index when your query combines filters in ways that a single index cannot serve.
- Add a function to render the query results in the browser by updating the DOM inside your index.html page.
// Render product results to the page
function renderProducts(snapshot) {
const container = document.getElementById("products");
container.innerHTML = "";
snapshot.forEach((doc) => {
const product = doc.data();
const div = document.createElement("div");
div.className = "product-card";
div.innerHTML = `
<h3>${product.name}</h3>
<p>${product.description}</p>
<p>Price: $${product.price}</p>
<p>Category: ${product.categoryName}</p>
<p>Tags: ${product.tags.join(", ")}</p>
<p>Stock: ${product.stock}</p>
`;
container.appendChild(div);
});
}
What does this function do?
The renderProducts function takes a Firestore query snapshot and loops through each document. It creates a card element for each product and injects it into the page. Notice how categoryName is available directly on the product document because of the denormalization you set up in Step 2. No second query needed.
Hit a Query Limitation and Create a Composite Index
Not all query combinations work automatically. When you combine a filter on one field with a range filter or sort on a different field, Firestore needs a composite index that covers both fields together. Without it, the query fails with an error.
- Add a query that filters by category, filters by price range, and sorts by price.
import { orderBy } from "firebase/firestore";
// This query will FAIL without a composite index
async function getExpensiveElectronics() {
const productsRef = collection(db, "products");
const q = query(
productsRef,
where("categoryId", "==", "electronics"),
where("price", ">", 50),
orderBy("price")
);
try {
const snapshot = await getDocs(q);
renderProducts(snapshot);
} catch (error) {
console.error("Query failed:", error.message);
// The error message contains a link to create the missing index
}
}
getExpensiveElectronics();
When you run this query, Firestore returns an error. Open your browser's developer console to see it.
Seeing an index error? That is expected.
The error message includes a direct URL to the Firebase Console that pre-fills the composite index configuration for you. This is one of Firestore's most helpful developer experience features.
- Click the URL in the error message to open the Firebase Console.
- Review the pre-filled index configuration and click Create index.
- Wait for the index to build. For small datasets, this usually takes under a minute.
- Re-run the query once the index status shows as Enabled.
The query now returns filtered and sorted results.
Why does Firestore require composite indexes?
Firestore guarantees that every query's performance scales with the size of the result set, not the total size of the database. A collection with 10 million documents returns 5 results just as fast as a collection with 100 documents returning 5 results. Composite indexes make this guarantee possible by pre-computing the sorted, filtered result set.
In Postgres, the query planner decides at runtime whether to use an index or do a full table scan. Firestore removes that ambiguity entirely. If the index does not exist, the query simply will not run.
Pro tip: index limits
Each Firestore database supports up to 200 composite indexes without billing enabled, and up to 1,000 with billing enabled. Plan your query patterns carefully to stay within these limits.
Run a Multi-Field Inequality Filter
Firestore now supports range and inequality filters on multiple fields in a single query, with a limit of up to 10 fields. This is a relatively recent capability. However, the order of fields in your orderBy clause directly affects which composite index Firestore uses and how many index entries it needs to scan.
- Add a query that filters products where price > 20 AND rating > 4.
// Multi-field inequality: filter on both price and rating
async function getHighRatedAffordable() {
const productsRef = collection(db, "products");
const q = query(
productsRef,
where("rating", ">", 4),
where("price", ">", 20),
orderBy("rating"),
orderBy("price")
);
try {
const snapshot = await getDocs(q);
renderProducts(snapshot);
console.log(`Found ${snapshot.size} products`);
} catch (error) {
console.error("Query failed:", error.message);
// If you see an index error, follow the link to create it
}
}
getHighRatedAffordable();
What does this query do?
This query applies inequality filters (>) on two different fields: rating and price. If Firestore prompts you to create another composite index, follow the link in the error message just like before.
Notice the order of the orderBy clauses. The rating field comes first because it is likely the more selective filter. If most products have a price above $20 but only a few have a rating above 4, putting rating first means Firestore scans fewer index entries before applying the second filter.
Why does field order matter in Firestore but not in Postgres?
In Postgres, the query planner automatically determines the most efficient execution order. It analyzes table statistics and picks the best index strategy at runtime. Firestore has no query planner. It relies entirely on the composite index you define, and the order of fields in that index determines the scan path.
Placing the most selective field first in your orderBy clause reduces the number of index entries Firestore needs to examine. This is a performance optimization you control directly, and it can make a dramatic difference on large datasets.
Pro tip: test both orderings
Try swapping the orderBy order to orderBy("price"), orderBy("rating"). The results will be the same, but Firestore may need to scan more index entries to find them. On a small seed dataset the difference is invisible, but on millions of documents it becomes critical.
You now have a solid understanding of how Firestore queries work and where they differ from SQL. You have built simple filters, compound queries, and multi-field inequality filters. You have also created composite indexes and learned why Firestore requires them. Next up, you will replace these one-time queries with real-time listeners that push live updates to the browser.
Set Up Real-Time Listeners
Your queries work, but right now they only fetch data once. If someone updates a product's price in the database, your browser still shows the old value until you refresh the page. Firestore can push changes to your app the instant they happen. In this step, you'll swap your one-time queries for real-time listeners that keep the UI in sync with the database automatically.
In This Step, Get Ready To:
- Replace one-time queries with real-time listeners using onSnapshot.
- Watch your app update live when data changes in the Firebase Console.
- Learn when to use listeners versus one-time reads.
Add a Real-Time Listener to the Products Collection
A one-time getDocs call fetches data and stops. An onSnapshot listener keeps a persistent connection open and fires a callback every time the data matching your query changes. The callback receives a snapshot that includes a docChanges() method telling you exactly which documents were added, modified, or removed.
- Open app.js in VS Code.
- Import onSnapshot from firebase/firestore alongside your existing imports.
import { onSnapshot } from "firebase/firestore";
- Replace your existing getDocs call with an onSnapshot listener that filters products by category and re-renders the product list whenever data changes.
// Build a query for products in a specific category
const q = query(
collection(db, "products"),
where("categoryId", "==", "electronics")
);
// Set up a real-time listener instead of a one-time fetch
const unsubscribe = onSnapshot(q, (snapshot) => {
// Clear the current product list
const productList = document.getElementById("product-list");
productList.innerHTML = "";
// Log which documents changed and how
snapshot.docChanges().forEach((change) => {
console.log(`${change.type}: ${change.doc.data().name}`);
});
// Re-render all products from the snapshot
snapshot.forEach((doc) => {
const product = doc.data();
const item = document.createElement("div");
item.className = "product-card";
item.innerHTML = `
<h3>${product.name}</h3>
<p>$${product.price}</p>
<p>Stock: ${product.stock}</p>
<p>Rating: ${product.rating}/5</p>
`;
productList.appendChild(item);
});
});
What does this code do?
onSnapshot takes a query and a callback function. The first time it fires, the callback receives every document matching the query (just like getDocs). After that, it fires again only when a matching document is added, modified, or removed.
The unsubscribe variable stores a function you can call later to detach the listener and close the connection.
- Save app.js and open your index.html in the browser.
- Open the browser's developer console to see the docChanges() log output.
You should see all your electronics products rendered on the page. The console should show each document logged with a change type of added since this is the listener's initial load.
Test Live Updates from the Firebase Console
The real power of onSnapshot becomes obvious when you change data outside your app and watch the UI react instantly.
- Open the Firebase Console in a second browser tab.
- Navigate to Firestore database in the left panel.
- Expand the products collection and click on one of your electronics products.
- Edit the price field to a new value (e.g., change it from 99 to 149).
- Click Update to save the change.
Switch back to your app's browser tab. The product's price updates instantly without a page refresh. In the developer console, you should see a log entry with the change type modified for the product you just edited.
How does this work under the hood?
The Firestore SDK maintains a persistent connection to the server. When any document matching your query changes, the server pushes the update to your client immediately. This is what makes Firestore a strong fit for real-time apps like chat, dashboards, and live inventory displays.
- Try editing the stock field of another product in the console and confirm that your app updates live as well.
Know When to Use Listeners Versus One-Time Reads
Real-time listeners are powerful, but they are not always the right choice. Each active listener holds an open connection to Firestore. Every database has a limit of 1 million concurrent connections.
When should I use a listener versus getDocs?
Use onSnapshot for data that changes frequently and the user needs to see live. Stock levels, prices, and order statuses are good examples.
Use a one-time getDocs call for data that rarely changes, like categories or static configuration. There is no need to hold an open connection for data that updates once a month.
When the user navigates away from a page or no longer needs the data, you should detach the listener to free up the connection. Call the unsubscribe function that onSnapshot returned.
// When the user leaves the page or you no longer need updates
unsubscribe();
Pro tip: clean up listeners
In a production app, you would call unsubscribe() in a cleanup handler. For example, in React you would call it inside a useEffect return function. Forgetting to detach listeners is a common source of memory leaks and unexpected billing.
Your product catalog now updates in real time. Next up, you'll build a "Buy" button that uses transactions to safely decrement inventory without race conditions.
Build Transactions for Inventory Management
Your product catalog now updates in real time whenever data changes. But real-time reads are only half the story. What happens when two customers try to buy the last item at the exact same moment? In this step, you'll build a "Buy" button that safely decrements inventory using Firestore transactions, preventing the classic race condition that plagues naive update patterns.
In This Step, Get Ready To:
- Understand why a simple update creates a race condition for inventory management.
- Implement a Firestore transaction that reads stock, validates it, and decrements atomically.
- Test the transaction by purchasing items and handling out-of-stock scenarios.
Understand the Race Condition Problem
Before writing any transaction code, you need to understand why a straightforward update is dangerous for inventory. Imagine a product has stock = 1. Two users click "Buy" at the same time. Both read stock = 1, both decrement it, and both write stock = 0. The product was sold twice, but only one unit existed. The stock could even go negative.
You might think of using increment(-1) with updateDoc. That avoids the double-decrement issue, but it cannot check whether stock is greater than zero before decrementing. You need a read-then-write operation that is atomic. That is exactly what a transaction provides.
- Add a "Buy" button to each product card in your index.html file.
Inside the product rendering function in your app.js, add a button element for each product card:
// Inside your product card rendering function
const buyButton = document.createElement('button');
buyButton.textContent = `Buy (${product.stock} left)`;
buyButton.onclick = () => handlePurchase(product.id);
card.appendChild(buyButton);
What does this code do?
This creates a button for each product that displays the current stock count. When clicked, it calls a handlePurchase function with the product's document ID. You'll build that function next.
Implement the Transaction
A Firestore transaction wraps a read and a write into a single atomic operation. Inside the transaction, you read the current stock value, check if it is greater than zero, and only then decrement it. If another write modifies the document between your read and your write, Firestore automatically retries the entire transaction.
- Import runTransaction and doc from firebase/firestore at the top of your app.js file.
import { runTransaction, doc } from "firebase/firestore";
- Add the handlePurchase function to app.js.
async function handlePurchase(productId) {
// Get a reference to the product document
const productRef = doc(db, "products", productId);
try {
await runTransaction(db, async (transaction) => {
// Read the current product data inside the transaction
const productSnap = await transaction.get(productRef);
if (!productSnap.exists()) {
throw new Error("Product does not exist.");
}
const currentStock = productSnap.data().stock;
// Check if stock is available before decrementing
if (currentStock <= 0) {
throw new Error("Out of stock.");
}
// Decrement stock by 1
transaction.update(productRef, { stock: currentStock - 1 });
});
console.log("Purchase successful!");
} catch (error) {
console.error("Purchase failed:", error.message);
alert(error.message);
}
}
What does this transaction do?
The runTransaction function takes a callback where all reads and writes happen atomically. First, transaction.get() reads the product document. Then the code checks if stock is above zero. Only if stock is available does transaction.update() decrement the value.
If another user modifies the product document between the read and the write, Firestore detects the conflict and retries the entire callback automatically. This guarantees that stock never goes below zero, no matter how many users click "Buy" simultaneously.
Pro tip: keep transactions pure
Transaction functions may run multiple times due to retries. Do not modify external application state (like updating a UI counter or sending an email) inside the transaction callback. Only perform Firestore reads and writes inside it. Handle side effects after the transaction resolves successfully.
Test the Transaction
With the real-time listener from the previous step still active, your product list will update the moment the transaction commits. This lets you see the stock count change live.
- Open your app in the browser and click the Buy button on any product.
- Watch the stock count decrement in real time on the product card.
- Open the Firebase Console in another tab and verify the stock field value has decreased on the product document.
What should I see?
Each time you click Buy, the button text should update to show one fewer item in stock. The console should log "Purchase successful!" with each click.
Now test the out-of-stock scenario.
- In the Firebase Console, manually set a product's stock field to 0.
- Go back to your app and click Buy on that product.
- Confirm that an alert appears with the message "Out of stock." and the stock remains at 0.
How do Firestore transactions differ on client vs. server?
The web SDK uses optimistic concurrency control. It reads the document, runs your logic, then attempts to commit. If the document changed since the read, Firestore retries the callback. Server-side SDKs (like the Admin SDK) use pessimistic locking instead, which holds a lock during the transaction.
One important limitation: transactions fail when the client is offline. They require a round trip to the Firestore backend to guarantee consistency.
Stock not updating in the browser?
Make sure your real-time listener from Step 5 is still active. The transaction updates the database, but you need the onSnapshot listener to push the change to your UI. Also check the browser console for any error messages. Help me debug my transaction.
Your catalog now handles concurrent purchases safely. No overselling, no negative stock. Next up, you'll lock down the database with security rules that control who can read and write your data.
Write Security Rules
Your product catalog has real-time updates and transactional inventory management. There is one major problem: the database is still in test mode, which means anyone on the internet can read and overwrite all your data.
Firestore security rules let you control exactly who can read and write each collection. They run on the server, so no client-side code can bypass them. In this step, you will replace the wide-open test mode rules with production rules that validate every write.
In This Step, Get Ready To:
- Replace test mode rules with production rules that separate public reads from authenticated writes.
- Add data validation rules that reject invalid prices, stock values, and review ratings.
- Learn why security rules are not filters and how this catches Postgres developers off guard.
Switch From Test Mode to Production Rules
Test mode allows anyone to read and overwrite your data for 30 days, then blocks all access entirely. Production rules give you granular control over every operation.
- In the Firebase Console, click Firestore Database in the left panel.
- Click the Rules tab at the top.
- Replace the entire contents of the rules editor with the following:
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// Products: anyone can read, only authenticated users can write
match /products/{productId} {
allow get, list: if true;
allow write: if request.auth != null;
// Reviews subcollection: anyone can read, only authenticated users can create
match /reviews/{reviewId} {
allow get, list: if true;
allow create: if request.auth != null;
}
}
// Categories: anyone can read, only authenticated users can write
match /categories/{categoryId} {
allow get, list: if true;
allow write: if request.auth != null;
}
}
}
What do these rules do?
Instead of a blanket allow read, write, these rules use granular operations. allow get, list controls read access (single document fetches and collection queries). allow write covers create, update, and delete operations.
For the reviews subcollection, we only allow allow create instead of full write access. This means authenticated users can add reviews but cannot edit or delete existing ones.
Add Data Validation to the Rules
Authentication alone is not enough. An authenticated user could still write a product with a negative price or a review with a rating of 99. Data validation in security rules enforces constraints at the server level, similar to CHECK constraints in Postgres.
- Update your rules to include validation logic. Replace the full rules editor content with the following:
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// Products: public reads, validated authenticated writes
match /products/{productId} {
allow get, list: if true;
allow write: if request.auth != null
&& request.resource.data.price > 0
&& request.resource.data.stock >= 0;
// Reviews: public reads, validated authenticated creates only
match /reviews/{reviewId} {
allow get, list: if true;
allow create: if request.auth != null
&& request.resource.data.rating >= 1
&& request.resource.data.rating <= 5
&& request.resource.data.userId == request.auth.uid;
}
}
// Categories: public reads, authenticated writes
match /categories/{categoryId} {
allow get, list: if true;
allow write: if request.auth != null;
}
}
}
What does the validation do?
The request.resource.data object contains the data that the client is trying to write. The product rules ensure that price is always positive and stock never goes negative.
The review rules do two things. They validate that ratings fall between 1 and 5. They also check that request.resource.data.userId == request.auth.uid, which prevents users from writing reviews as someone else.
Publish and Test the Rules
- Click Publish in the rules editor to deploy your new rules.
- Open your browser's developer console on your app page.
- Try writing a product with a negative price using the Firebase SDK directly in the console:
// This should be DENIED by your security rules
import { doc, setDoc } from "firebase/firestore";
await setDoc(doc(db, "products", "test-bad-price"), {
name: "Invalid Product",
price: -10,
stock: 5,
categoryId: "electronics",
categoryName: "Electronics"
});
The write should fail with a permission denied error. Your rules are working.
What should I see?
You should see a FirebaseError: Missing or insufficient permissions error in the console. This confirms that Firestore rejected the write because the price was negative.
Security Rules Are Not Filters
This is the concept that catches most Postgres developers off guard. In SQL, row-level security silently filters out rows the user cannot access. Firestore works differently.
If your query could potentially return even one document the user does not have permission to read, the entire query fails. Firestore evaluates rules against the query's potential result set, not the actual results. You must structure your queries so that every document in the possible result set is readable by the current user.
Pro tip: test rules without deploying
The Firebase Console has a Rules Playground tab in the Firestore rules editor. You can simulate reads and writes with different authentication states to test your rules before publishing them.
Your database is now locked down with rules that validate every write at the server level. No client-side code can bypass these checks. Next up, take on the Secret Mission and build a Related Products feature using collection group queries.
Secret mission
Add Related Products via Collection Group Queries
Your catalog works great for browsing products one at a time. But what if you could find products that a specific reviewer loved across the entire database? Collection group queries let you search across every reviews subcollection at once, unlocking a powerful "Related Products" feature.
Before you go
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project uses Firestore's free tier (50K reads, 20K writes, and 1 GiB storage per day), so there are no ongoing costs as long as you stay within those limits.
Resources you used:
- Firebase project (contains your Firestore database, security rules, and composite indexes).
- Firestore database with products, categories, and reviews collections.
- Local project folder with index.html, app.js, seed.js, and node_modules.
- Composite indexes created in the Firebase Console.
- Google account used to sign in to Firebase.
Keep everything running
No action needed. Choose this if you are still actively building or plan to extend the project soon.
- Your Firestore database will remain active and accessible via the Firebase Console.
- Your local project files stay on your machine as-is.
- Keep in mind that test mode security rules expire after 30 days. If you set up production rules in Step 7, your database is already protected.
Pause - I'll come back to this later
Free up local resources while keeping your Firebase project intact for later.
- Stop any local development server you have running.
- Delete the node_modules folder to free up disk space. You can reinstall it later with npm install.
- Leave the Firebase project and Firestore database as they are. There are no ongoing costs within the free tier.
- If you are still in test mode, update your security rules to production rules before the 30-day expiry to avoid losing all access.
Delete - I don't want to use this again
Remove all project resources and start fresh.
- Delete the Firestore database. In the Firebase Console, go to Firestore Database, click the three-dot menu, and select Delete database.
- Delete the Firebase project. Go to Project Settings (gear icon), scroll to the bottom, and click Delete project. Confirm the deletion.
- Delete the local project folder from your machine.
Mission accomplished
Nice work! You just built a fully functional e-commerce product catalog powered by Firestore, covering the core NoSQL concepts that matter in production.
Here's what you learned:
- Designed a NoSQL data model with root-level collections, subcollections, and denormalized fields, understanding the tradeoffs compared to a relational Postgres schema.
- Built compound queries with composite indexes, real-time listeners that push live updates to the browser, and transactions that prevent inventory race conditions.
- Wrote security rules with data validation that protect the database at the server level, learning that security rules are not filters.
- Used collection group queries to search across every reviews subcollection in the database and build a Related Products feature.
Ready to quiz yourself?