About this course
<p>Most apps eventually need to store and manipulate data. And if you're building with Firebase, that data lives in Firestore, Google's flexible, scalable NoSQL document database.</p>
<p>But before you can confidently create, read, update, or delete data, you need to understand how Firestore actually organizes information. It doesn't look like a SQL database, and treating it like one is the fastest way to end up with a messy, hard-to-query data structure.</p>
<p>In this tutorial, you'll learn how Firestore's NoSQL data model works, then build a small task management app to practice every CRUD operation with the Firebase Web SDK (v9+, modular). By the end, you'll be able to add tasks, query them, update nested fields and arrays, and delete data safely without leaving orphaned subcollections behind.</p>
<h3 id="heading-table-of-contents">Table of Contents</h3>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-how-firestore-structures-data">How Firestore Structures Data</a></p>
</li>
<li><p><a href="#heading-step-1-set-up-your-firebase-project">Step 1 – Set Up Your Firebase Project</a></p>
</li>
<li><p><a href="#heading-step-2-initialize-the-sdk">Step 2 – Initialize the SDK</a></p>
</li>
<li><p><a href="#heading-step-3-create-adding-tasks">Step 3 – Create: Adding Tasks</a></p>
</li>
<li><p><a href="#heading-step-4-read-querying-tasks">Step 4 – Read: Querying Tasks</a></p>
</li>
<li><p><a href="#heading-step-5-update-modifying-tasks">Step 5 – Update: Modifying Tasks</a></p>
</li>
<li><p><a href="#heading-step-6-delete-removing-tasks">Step 6 – Delete: Removing Tasks</a></p>
</li>
<li><p><a href="#heading-debugging-common-issues">Debugging Common Issues</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h3 id="heading-prerequisites">Prerequisites</h3>
<p>Before you start, make sure you have the following:</p>
<ul>
<li><p><strong>Node.js v18 or later</strong> (<code>node --version</code>)</p>
</li>
<li><p>A <strong>Google account</strong> to create a Firebase project (the free Spark plan is enough for this tutorial)</p>
</li>
<li><p>Basic familiarity with JavaScript, including <code>async</code>/<code>await</code> and ES modules</p>
</li>
<li><p>A code editor and a terminal</p>
</li>
</ul>
<p>You don't need prior experience with Firebase or NoSQL databases, as this guide builds that understanding from the ground up.</p>
<h2 id="heading-how-firestore-structures-data">How Firestore Structures Data</h2>
<p>If you're coming from a relational (SQL) background, the first thing to unlearn is the idea of tables with a fixed schema and foreign key joins. Firestore is a <strong>document-oriented NoSQL database</strong>, and it organizes data around two core concepts: <strong>collections</strong> and <strong>documents</strong>.</p>
<ul>
<li><p>A collection is a named bucket that holds documents. Think <code>tasks</code>, <code>users</code>, or <code>orders</code>.</p>
</li>
<li><p>A document is a single record inside a collection, identified by a unique ID. It stores data as key-value pairs, similar to a JSON object.</p>
</li>
</ul>
<p>Here's the catch that trips up a lot of newcomers: <strong>documents don't need to share the same fields</strong>. One <code>task</code> document can have a <code>dueDate</code> field while another doesn't. Firestore doesn't enforce a schema at the database level, that responsibility shifts to your application code.</p>
<h4 id="heading-nesting-and-subcollections">Nesting and subcollections</h4>
<p>Documents can hold two kinds of nested data:</p>
<ul>
<li><p><strong>Maps</strong>, which are objects nested directly inside a document (for example, a <code>metadata</code> field containing <code>{ priority, dueDate }</code>)</p>
</li>
<li><p><strong>Subcollections</strong>, which are entire collections nested under a specific document (for example, every task can have its own <code>comments</code> subcollection)</p>
</li>
</ul>
<p>This gives you a structure that looks like a tree:</p>
<pre><code class="language-plaintext">tasks (collection)
└── taskId (document)
├── title: "Article title"
├── completed: false
├── tags: ["writing", "firebase"]
├── metadata: { priority: "high", dueDate: <timestamp> }
└── comments (subcollection)
└── commentId (document)
├── text: "CRUD Article"
└── createdAt: <timestamp>
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/66f71ee288cc311f84e563bc/8c16db01-6335-4d00-92c0-8bbf392bd2e9.jpg" alt="A tree diagram illustrating Firestore's data hierarchy: a "tasks" collection contains a "taskId" document, which holds fields such as title, completed, tags, and a nested metadata map, alongside a "comments" subcollection containing individual comment documents with their own text and createdAt fields" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">
<h4 id="heading-supported-data-types">Supported data types</h4>
<p>Firestore documents can store several native types. The ones you'll use most often are:</p>
<table>
<thead>
<tr>
<th>Type</th>
<th>Example</th>
</tr>
</thead>
<tbody><tr>
<td><code>string</code></td>
<td><code>"Write CRUD article"</code></td>
</tr>
<tr>
<td><code>number</code></td>
<td><code>42</code></td>
</tr>
<tr>
<td><code>boolean</code></td>
<td><code>true</code></td>
</tr>
<tr>
<td><code>array</code></td>
<td><code>["writing", "firebase"]</code></td>
</tr>
<tr>
<td><code>map</code></td>
<td><code>{ priority: "high" }</code></td>
</tr>
<tr>
<td><code>timestamp</code></td>
<td><code>Timestamp.now()</code></td>
</tr>
<tr>
<td><code>reference</code></td>
<td>a pointer to another document</td>
</tr>
<tr>
<td><code>geopoint</code></td>
<td>a latitude/longitude pair</td>
</tr>
</tbody></table>
<h4 id="heading-why-this-matters-before-writing-crud-code">Why this matters before writing CRUD code</h4>
<p>Every CRUD operation you'll write later depends on this structure:</p>
<ul>
<li><p><strong>Create</strong> means adding a document to a collection, with an auto-generated or custom ID.</p>
</li>
<li><p><strong>Read</strong> means fetching either a single document by ID or a set of documents matching a query.</p>
</li>
<li><p><strong>Update</strong> means modifying fields on an existing document, including nested maps and arrays.</p>
</li>
<li><p><strong>Delete</strong> means removing a document, and Firestore will <em>not</em> automatically clean up its subcollections (a common gotcha you'll see in Step 6).</p>
</li>
</ul>
<p>With the mental model in place, let's set up a project and start writing code.</p>
<h3 id="heading-step-1-set-up-your-firebase-project">Step 1 – Set Up Your Firebase Project</h3>
<p>Head to the <a href="https://console.firebase.google.com/">Firebase console</a> and create a new project.</p>
<ol>
<li><p>Click <strong>Add project</strong>, give it a name (for example: <code>crud-tasks-demo</code>), and follow the setup wizard (Google Analytics is optional for this tutorial).</p>
</li>
<li><p>Once the project is created, open the left sidebar and click <strong>Databases and Storage</strong> and then <strong>Firestore</strong>.</p>
</li>
<li><p>Click <strong>Create database</strong>. Choose a location close to you, and for this tutorial, start in <strong>test mode</strong> so you can read and write without configuring security rules yet.</p>
</li>
</ol>
<p><strong>Note:</strong> Test mode leaves your database open to anyone for 30 days. Never ship an app to production without proper <a href="https://firebase.google.com/docs/firestore/security/get-started">Firestore security rules</a>, we'll touch on this in the Debugging section.</p>
<p>You should now see an empty Firestore database, ready to receive your first collection.</p>
<h3 id="heading-step-2-initialize-the-sdk">Step 2 – Initialize the SDK</h3>
<p>Create a new project folder and install the Firebase Web SDK:</p>
<pre><code class="language-shell">mkdir firestore-crud-demo && cd firestore-crud-demo
npm init -y
npm install firebase
</code></pre>
<p>Grab your project's config object from <strong>Project settings - General - Your apps - Web app</strong> in the Firebase console (register a new web app if you haven't yet).</p>
<p>Create a <code>firebase-config.js</code> file:</p>
<pre><code class="language-javascript">// firebase-config.js
import { initializeApp } from "firebase/app";
import { getFirestore } from "firebase/firestore";
const firebaseConfig = {
apiKey: "YOUR_API_KEY",
authDomain: "YOUR_PROJECT_ID.firebaseapp.com",
projectId: "YOUR_PROJECT_ID",
storageBucket: "YOUR_PROJECT_ID.appspot.com",
messagingSenderId: "YOUR_SENDER_ID",
appId: "YOUR_APP_ID",
};
const app = initializeApp(firebaseConfig);
export const db = getFirestore(app);
</code></pre>
<p>Every CRUD example from here on imports <code>db</code> from this file. Keep your actual config values out of version control (use environment variables in a real project).</p>
<h3 id="heading-step-3-create-adding-tasks">Step 3 – Create: Adding Tasks</h3>
<p>Firestore gives you two ways to create a document: let Firestore generate the ID, or set your own.</p>
<h4 id="heading-auto-generated-id-with-adddoc">Auto-generated ID with <code>addDoc()</code></h4>
<pre><code class="language-javascript">// create-task.js
import { collection, addDoc, Timestamp } from "firebase/firestore";
import { db } from "./firebase-config.js";
async function createTask() {
try {
const docRef = await addDoc(collection(db, "tasks"), {
title: "Write CRUD article",
completed: false,
tags: ["writing", "firebase"],
metadata: {
priority: "high",
dueDate: Timestamp.fromDate(new Date("2026-09-15")),
},
createdAt: Timestamp.now(),
});
console.log("Task created with ID:", docRef.id);
} catch (error) {
console.error("Error creating task:", error);
}
}
createTask();
</code></pre>
<h4 id="heading-custom-id-with-setdoc"><strong>Custom ID with</strong> <code>setDoc()</code></h4>
<p>Use this when you want to control the document ID yourself, for example, matching it to an ID from another system.</p>
<pre><code class="language-javascript">import { doc, setDoc } from "firebase/firestore";
import { db } from "./firebase-config.js";
async function createTaskWithId(taskId) {
await setDoc(doc(db, "tasks", taskId), {
title: "Review pull request",
completed: false,
tags: ["code-review"],
});
}
createTaskWithId("task-001");
</code></pre>
<h4 id="heading-adding-a-document-to-a-subcollection">Adding a document to a subcollection</h4>
<p>To add a comment under a specific task, you reference the parent document first:</p>
<pre><code class="language-javascript">import { collection, addDoc, Timestamp } from "firebase/firestore";
import { db } from "./firebase-config.js";
async function addComment(taskId, text) {
await addDoc(collection(db, "tasks", taskId, "comments"), {
text,
createdAt: Timestamp.now(),
});
}
addComment("task-001", "First draft done");
</code></pre>
<h3 id="heading-step-4-read-querying-tasks">Step 4 – Read: Querying Tasks</h3>
<h4 id="heading-fetching-a-single-document">Fetching a single document</h4>
<pre><code class="language-javascript">import { doc, getDoc } from "firebase/firestore";
import { db } from "./firebase-config.js";
async function getTask(taskId) {
const snapshot = await getDoc(doc(db, "tasks", taskId));
if (snapshot.exists()) {
console.log(snapshot.id, snapshot.data());
} else {
console.log("No such task.");
}
}
getTask("task-001");
</code></pre>
<h4 id="heading-fetching-an-entire-collection">Fetching an entire collection</h4>
<pre><code class="language-javascript">import { collection, getDocs } from "firebase/firestore";
import { db } from "./firebase-config.js";
async function getAllTasks() {
const snapshot = await getDocs(collection(db, "tasks"));
snapshot.forEach((doc) => {
console.log(doc.id, doc.data());
});
}
getAllTasks();
</code></pre>
<h4 id="heading-filtering-with-queries">Filtering with queries</h4>
<pre><code class="language-javascript">import { collection, query, where, orderBy, limit, getDocs } from "firebase/firestore";
import { db } from "./firebase-config.js";
async function getUrgentPendingTasks() {
const q = query(
collection(db, "tasks"),
where("completed", "==", false),
orderBy("metadata.priority"),
limit(10)
);
const snapshot = await getDocs(q);
snapshot.forEach((doc) => console.log(doc.id, doc.data()));
}
getUrgentPendingTasks();
</code></pre>
<p><strong>Heads up:</strong> combining <code>where()</code> on one field with <code>orderBy()</code> on another often requires a <strong>composite index</strong>. Firestore will throw an error in your console with a direct link to create it. More on this in Debugging.</p>
<h4 id="heading-real-time-updates-with-onsnapshot">Real-time updates with <code>onSnapshot()</code></h4>
<p>Instead of fetching once, you can subscribe to live changes. This is useful for a task list that updates instantly across devices:</p>
<pre><code class="language-javascript">import { collection, onSnapshot } from "firebase/firestore";
import { db } from "./firebase-config.js";
const unsubscribe = onSnapshot(collection(db, "tasks"), (snapshot) => {
snapshot.docChanges().forEach((change) => {
console.log(change.type, change.doc.id, change.doc.data());
});
});
// Call unsubscribe() when you no longer need updates (e.g., component unmount)
</code></pre>
<h3 id="heading-step-5-update-modifying-tasks">Step 5 – Update: Modifying Tasks</h3>
<h4 id="heading-partial-update-with-updatedoc">Partial update with <code>updateDoc()</code></h4>
<p>Unlike <code>setDoc()</code>, <code>updateDoc()</code> only touches the fields you specify. Everything else on the document stays untouched.</p>
<pre><code class="language-javascript">import { doc, updateDoc } from "firebase/firestore";
import { db } from "./firebase-config.js";
async function completeTask(taskId) {
await updateDoc(doc(db, "tasks", taskId), {
completed: true,
});
}
completeTask("task-001");
</code></pre>
<h4 id="heading-updating-a-nested-field-with-dot-notation">Updating a nested field with dot notation</h4>
<p>You don't need to rewrite the whole <code>metadata</code> map to change one property inside it:</p>
<pre><code class="language-javascript">await updateDoc(doc(db, "tasks", "task-001"), {
"metadata.priority": "low",
});
</code></pre>
<h4 id="heading-updating-arrays-safely">Updating arrays safely</h4>
<p>Directly overwriting an array field is risky in concurrent scenarios. Use <code>arrayUnion()</code> and <code>arrayRemove()</code> instead:</p>
<pre><code class="language-javascript">import { doc, updateDoc, arrayUnion, arrayRemove } from "firebase/firestore";
import { db } from "./firebase-config.js";
async function addTag(taskId, tag) {
await updateDoc(doc(db, "tasks", taskId), {
tags: arrayUnion(tag),
});
}
async function removeTag(taskId, tag) {
await updateDoc(doc(db, "tasks", taskId), {
tags: arrayRemove(tag),
});
}
</code></pre>
<p><code>arrayUnion()</code> won't add a duplicate value, and <code>arrayRemove()</code> removes every matching instance. Both operate atomically on the server.</p>
<h3 id="heading-step-6-delete-removing-tasks">Step 6 – Delete: Removing Tasks</h3>
<h4 id="heading-deleting-a-document">Deleting a document</h4>
<pre><code class="language-javascript">import { doc, deleteDoc } from "firebase/firestore";
import { db } from "./firebase-config.js";
async function deleteTask(taskId) {
await deleteDoc(doc(db, "tasks", taskId));
}
deleteTask("task-001");
</code></pre>
<h4 id="heading-the-subcollection-trap">The subcollection trap</h4>
<p>Here's the gotcha mentioned earlier: deleting <code>tasks/task-001</code> does <strong>not</strong> delete its <code>comments</code> subcollection. Those comment documents become orphaned, they still exist in your database. They're just unreachable through the UI unless you know the path.</p>
<p>To clean up properly, delete the subcollection's documents first, then the parent:</p>
<pre><code class="language-javascript">import { collection, getDocs, doc, deleteDoc, writeBatch } from "firebase/firestore";
import { db } from "./firebase-config.js";
async function deleteTaskWithComments(taskId) {
const commentsRef = collection(db, "tasks", taskId, "comments");
const commentsSnapshot = await getDocs(commentsRef);
const batch = writeBatch(db);
commentsSnapshot.forEach((commentDoc) => {
batch.delete(commentDoc.ref);
});
batch.delete(doc(db, "tasks", taskId));
await batch.commit();
}
deleteTaskWithComments("task-001");
</code></pre>
<p><code>writeBatch()</code> groups multiple deletes into one atomic operation. Either all of them succeed, or none do.</p>
<h4 id="heading-deleting-a-single-field">Deleting a single field</h4>
<p>If you only want to remove one field without deleting the whole document, use <code>deleteField()</code>:</p>
<pre><code class="language-javascript">import { doc, updateDoc, deleteField } from "firebase/firestore";
import { db } from "./firebase-config.js";
await updateDoc(doc(db, "tasks", "task-001"), {
metadata: deleteField(),
});
</code></pre>
<h3 id="heading-debugging-common-issues">Debugging Common Issues</h3>
<h4 id="heading-firebaseerror-missing-or-insufficient-permissions"><code>FirebaseError: Missing or insufficient permissions</code></h4>
<p>Your security rules are blocking the request. If you're still in test mode, check whether your 30-day window expired (rules revert to deny-all after that). For a real app, review your rules in <strong>Firestore</strong> and then <strong>Rules</strong> and make sure they match the paths you're reading/writing, including subcollections, which need their own rule blocks.</p>
<h4 id="heading-function-adddoc-called-with-invalid-data-unsupported-field-value-undefined"><code>Function addDoc() called with invalid data. Unsupported field value: undefined</code></h4>
<p>Firestore rejects <code>undefined</code> values outright, unlike <code>null</code>, which is allowed. This usually happens when a form field is empty and you pass it straight into your write call. Filter out <code>undefined</code> fields before writing, or default them to <code>null</code>.</p>
<h4 id="heading-the-query-requires-an-index"><code>The query requires an index</code></h4>
<p>This shows up when you combine <code>where()</code> and <code>orderBy()</code> on different fields, as in the Step 4 example. Firestore can't serve that query with automatic indexes. The error message includes a direct link that pre-fills the composite index for you in the console, click it, wait a minute or two for the index to build, and rerun your query.</p>
<h4 id="heading-reads-adding-up-fast-quota-warnings">Reads adding up fast / quota warnings</h4>
<p>Every document returned by <code>getDocs()</code> counts as a read, even inside a loop calling <code>getDoc()</code> repeatedly. Avoid fetching a whole collection just to filter it client-side, push filtering into your query with <code>where()</code> instead, and use <code>limit()</code> on anything that could grow unbounded.</p>
<h4 id="heading-orphaned-subcollections-after-delete">Orphaned subcollections after delete</h4>
<p>If you notice documents you thought you deleted still consuming storage or showing up in exports, check for subcollections under the deleted document's path. As shown in Step 6, <code>deleteDoc()</code> never cascades, cleanup is always your responsibility.</p>
<img src="https://cdn.hashnode.com/uploads/covers/66f71ee288cc311f84e563bc/6f5d1798-3798-4e05-9ceb-073d8857e15c.jpg" alt="A circular flow diagram showing the four CRUD operations as a continuous cycle, Create, Read, Update, and Delete, each labeled with its corresponding Firestore JavaScript functions (addDoc/setDoc, getDoc/getDocs/onSnapshot, updateDoc/arrayUnion, deleteDoc/writeBatch), illustrating how these operations connect in a typical data lifecycle." style="display:block;margin:0 auto" width="600" height="400" loading="lazy">
<h2 id="heading-conclusion">Conclusion</h2>
<p>You now have a working mental model of Firestore's structure and hands-on experience with every CRUD operation using the Web SDK v9+. Here's a quick recap:</p>
<table>
<thead>
<tr>
<th>Operation</th>
<th>Key functions</th>
</tr>
</thead>
<tbody><tr>
<td>Create</td>
<td><code>addDoc()</code>, <code>setDoc()</code></td>
</tr>
<tr>
<td>Read</td>
<td><code>getDoc()</code>, <code>getDocs()</code>, <code>query()</code>, <code>onSnapshot()</code></td>
</tr>
<tr>
<td>Update</td>
<td><code>updateDoc()</code>, <code>arrayUnion()</code>, <code>arrayRemove()</code></td>
</tr>
<tr>
<td>Delete</td>
<td><code>deleteDoc()</code>, <code>deleteField()</code>, <code>writeBatch()</code></td>
</tr>
</tbody></table>
<p>From here, there are a few natural next steps once you're comfortable with the basics:</p>
<ul>
<li><p><strong>Transactions</strong>, for reads and writes that must succeed or fail together (for example, transferring a task between two users)</p>
</li>
<li><p><strong>Batch writes</strong>, which you already saw in Step 6. They're useful anytime you need to touch multiple documents atomically</p>
</li>
<li><p><strong>Composite indexes</strong>, for more advanced filtering and sorting combinations</p>
</li>
<li><p><strong>Pagination</strong> with <code>startAfter()</code>, for loading large collections in chunks instead of all at once</p>
</li>
</ul>
<p>If you haven't already, it's worth revisiting how to model your data <em>before</em> you write queries against it. Decisions made at the modeling stage (like whether to nest data or use a subcollection) directly shape which of these CRUD patterns will feel natural versus awkward later on.</p>