Cheatsheet Firebase / Firestore
Plataforma NoSQL da Google para apps em tempo real e cloud
Firebase / Firestore
Setup and Configuration
Install SDK (Web v9+)
npm install firebase
// Import only what you need (tree-shaking)
import { initializeApp } from "firebase/app";
import { getFirestore } from "firebase/firestore";
import { getAuth } from "firebase/auth";The modular v9+ SDK enables tree-shaking — it only includes what you use in the bundle. Install with npm install firebase and import specific functions from each module.
References (collection and doc)
import { collection, doc } from "firebase/firestore";
// Collection reference
const usersRef = collection(db, "users");
// Reference to a specific document
const userRef = doc(db, "users", "user123");
// Subcollection reference
const postsRef = collection(db, "users/user123/posts");collection() points to a collection and doc() to a document. They are just references — no read happens until you call getDoc() or getDocs().
Initialize App
const firebaseConfig = {
apiKey: "AIza...",
authDomain: "my-app.firebaseapp.com",
projectId: "my-app",
storageBucket: "my-app.appspot.com",
messagingSenderId: "123456",
appId: "1:123:web:abc",
};
const app = initializeApp(firebaseConfig);
const db = getFirestore(app);The firebaseConfig object comes from the Firebase console. initializeApp() creates the instance and getFirestore() returns the database reference.
Multiple Apps
import { initializeApp } from "firebase/app";
import { getFirestore } from "firebase/firestore";
const app1 = initializeApp(config1);
const app2 = initializeApp(config2, "secondary");
const db1 = getFirestore(app1);
const db2 = getFirestore(app2);You can have multiple instances by passing a name the the second argument of initializeApp(). Useful for connecting to different Firebase projects in the same app.
Firebase CLI
npm install -g firebase-tools firebase login firebase init // project setup firebase deploy // publish rules/functions firebase emulators:start // local emulators
firebase-tools is the official CLI. firebase init configures the project locally and firebase deploy publishes rules, functions and hosting.
Environment Variables
// .env
VITE_FIREBASE_API_KEY=AIza...
VITE_FIREBASE_PROJECT_ID=my-app
// firebase.js
const config = {
apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
};Never hardcode credentials. Use environment variables with the VITE_ prefix (Vite) or NEXT_PUBLIC_ (Next.js) to expose them to the client safely.
Data Structure
// Firestore: Collections → Documents → Fields
users/ // collection
user123/ // document (ID: user123)
name: "Anna" // field
age: 30
posts/ // subcollection
post1/
title: "Hello"Firestore is a document-oriented NoSQL database. Data is organized into collections that contain documents, which can have nested subcollections.
Supported Data Types
await setDoc(doc(db, "types", "example"), {
text: "string",
number: 42,
float: 3.14,
boolean: true,
nothing: null,
date: new Date(),
geo: new GeoPoint(38.7, -9.1),
list: [1, 2, 3],
object: { nested: true },
});Firestore supports string, number, boolean, null, Date, GeoPoint, arrays and nested objects. Each document has a 1 MB limit.
CRUD Firestore
Create with setDoc
import { setDoc, doc } from "firebase/firestore";
await setDoc(doc(db, "users", "u1"), {
name: "Anna",
email: "ana@mail.com",
age: 30,
});setDoc() creates or overwrites a document with the specified ID. If the document already exists, it is completely replaced (unless you use merge: true).
Update (updateDoc)
import { updateDoc, doc, increment } from "firebase/firestore";
await updateDoc(doc(db, "posts", "p1"), {
title: "Edited title",
likes: increment(1),
editedAt: new Date(),
});updateDoc() modifies only the specified fields without affecting the rest. increment() adds atomically. Throws an error if the document does not exist.
Create with addDoc (Auto ID)
import { addDoc, collection } from "firebase/firestore";
const ref = await addDoc(collection(db, "posts"), {
title: "New post",
content: "Text here...",
likes: 0,
created: new Date(),
});
console.log("Generated ID:", ref.id);addDoc() automatically generates a unique ID. Ideal when you don't need to control the identifier. Returns the DocumentReference with the generated ref.id.
Merge (Partial setDoc)
import { setDoc, doc } from "firebase/firestore";
// Only updates the given fields, keeps the others
await setDoc(
doc(db, "users", "u1"),
{ age: 31, city: "Lisbon" },
{ merge: true }
);With { merge: true }, setDoc() does a partial update instead of overwriting. Creates the document if it does not exist. Useful for updates without knowing all the fields.
Read Document (getDoc)
import { getDoc, doc } from "firebase/firestore";
const snap = await getDoc(doc(db, "users", "u1"));
if (snap.exists()) {
console.log("ID:", snap.id);
console.log("Data:", snap.data());
} else {
console.log("Document does not exist");
}getDoc() returns a DocumentSnapshot. Use snap.exists() to check whether it exists and snap.data() to get the fields the an object.
Delete Document (deleteDoc)
import { deleteDoc, doc } from "firebase/firestore";
await deleteDoc(doc(db, "users", "u1"));
// Delete a specific field (without removing the doc)
import { updateDoc, deleteField } from "firebase/firestore";
await updateDoc(doc(db, "users", "u2"), {
oldField: deleteField(),
});deleteDoc() removes the whole document. To delete just one field, use deleteField() inside updateDoc(). Subcollections are not deleted automatically.
Read Collection (getDocs)
import { getDocs, collection } from "firebase/firestore";
const snap = await getDocs(collection(db, "users"));
console.log("Total:", snap.size);
snap.forEach((doc) => {
console.log(doc.id, "=>", doc.data());
});getDocs() returns a QuerySnapshot with all the documents. snap.size gives the total and snap.forEach() iterates over each document.
Special Fields
import {
serverTimestamp, arrayUnion,
arrayRemove, increment
} from "firebase/firestore";
await updateDoc(ref, {
updated: serverTimestamp(),
tags: arrayUnion("javascript"),
removed: arrayRemove("old"),
views: increment(1),
});serverTimestamp() uses the server time. arrayUnion() adds without duplicating, arrayRemove() removes and increment() adds atomically.
Queries
where (Basic Filters)
import { query, where, getDocs } from "firebase/firestore";
const q = query(
collection(db, "users"),
where("age", ">=", 18),
where("active", "==", true)
);
const snap = await getDocs(q);where() filters documents by field. You can chain multiple where() — they work the AND. The first argument is the field, the second the operator.
Count Documents (count)
import { getCountFromServer, collection } from "firebase/firestore";
const snap = await getCountFromServer(collection(db, "users"));
console.log("Total:", snap.data().count);
// With a filter
const q = query(collection(db, "posts"), where("public", "==", true));
const countSnap = await getCountFromServer(q);getCountFromServer() counts documents without downloading them — cheaper in reads. Works with filters. The result is in snap.data().count.
Available Operators
where("field", "==", value)
where("field", "!=", value)
where("field", ">", 10)
where("field", ">=", 10)
where("field", "<", 100)
where("field", "<=", 100)
where("field", "in", [1, 2, 3])
where("field", "not-in", [4, 5])
where("tags", "array-contains", "js")
where("tags", "array-contains-any", ["js", "ts"])Comparison and array operators. array-contains checks whether an array includes the value. in accepts up to 30 values. != also excludes documents without the field.
Subcollection Queries
import { collectionGroup, query, where } from "firebase/firestore";
// Search in ALL "comments" subcollections
const q = query(
collectionGroup(db, "comments"),
where("author", "==", "ana@mail.com")
);
const snap = await getDocs(q);collectionGroup() searches across all subcollections with the same name, in any document. Requires a special index enabled in the console.
Sorting (orderBy)
import { query, orderBy, getDocs } from "firebase/firestore";
const q = query(
collection(db, "posts"),
orderBy("created", "desc"),
orderBy("title", "asc")
);
const snap = await getDocs(q);orderBy() sorts by a field. The second argument is "asc" (default) or "desc". You can chain them for secondary sorting.
Query Limitations
// ❌ You cannot combine != with orderBy on another field // ❌ Max. 1 inequality filter per query // ❌ in / array-contains-any: max. 30 values // ❌ orderBy + where on different fields → composite index // ✅ Solution: create a composite index in the console // or split into two queries on the client
Firestore has limitations: only one inequality operator per query, and orderBy on a field different from the where requires a composite index configured in the console.
Pagination (limit and startAfter)
import { query, orderBy, limit, startAfter } from "firebase/firestore";
// First page
const q1 = query(collection(db, "posts"), orderBy("created"), limit(10));
const snap1 = await getDocs(q1);
const last = snap1.docs[snap1.docs.length - 1];
// Next page
const q2 = query(collection(db, "posts"), orderBy("created"), startAfter(last), limit(10));limit() restricts the number of results. startAfter() receives the last document of the previous page the a cursor for efficient pagination.
startAt and endAt (Ranges)
import { query, orderBy, startAt, endAt } from "firebase/firestore";
// Documents between A and M
const q = query(
collection(db, "users"),
orderBy("name"),
startAt("A"),
endAt("M\uf8ff")
);
const snap = await getDocs(q);startAt() and endAt() define inclusive bounds. startAfter() and endBefore() are exclusive. The \uf8ff character guarantees full prefix inclusion.
Real Time
onSnapshot (Document)
import { onSnapshot, doc } from "firebase/firestore";
const unsub = onSnapshot(doc(db, "users", "u1"), (snap) => {
if (snap.exists()) {
console.log("Current data:", snap.data());
}
});
// Stop listening
unsub();onSnapshot() creates a real-time listener. It runs immediately with the current data and then on every change. Returns an unsub() function to cancel.
Metadata (Data Source)
onSnapshot(ref, (snap) => {
const source = snap.metadata.fromCache ? "local cache" : "server";
const pending = snap.metadata.hasPendingWrites;
console.log(`Source: ${source} | Pending write: ${pending}`);
});snap.metadata.fromCache indicates whether the data came from the offline cache. hasPendingWrites shows whether there are local writes not yet synced to the server.
onSnapshot (Collection)
const unsub = onSnapshot(collection(db, "messages"), (snapshot) => {
snapshot.docChanges().forEach((change) => {
if (change.type === "added") {
console.log("New:", change.doc.data());
}
if (change.type === "modified") {
console.log("Edited:", change.doc.data());
}
if (change.type === "removed") {
console.log("Removed:", change.doc.id);
}
});
});On collections, snapshot.docChanges() shows exactly what changed: "added", "modified" or "removed". More efficient than re-rendering everything.
Include Metadata Changes
// By default, it does not fire when only metadata changes
// To also receive metadata changes:
const unsub = onSnapshot(ref, {
includeMetadataChanges: true,
}, (snap) => {
// Fires when data OR metadata change
console.log("fromCache:", snap.metadata.fromCache);
});With includeMetadataChanges: true, the listener also fires when the sync state changes (e.g. from cache to server), not only when the data changes.
Listener with Query
import { query, where, orderBy, onSnapshot } from "firebase/firestore";
const q = query(
collection(db, "messages"),
where("room", "==", "general"),
orderBy("created", "desc")
);
const unsub = onSnapshot(q, (snap) => {
const msgs = snap.docs.map(d => ({ id: d.id, ...d.data() }));
render(msgs);
});You can pass a query to onSnapshot() instead of a collection. The listener only receives documents that match the filter.
Detach Listeners
// Keep a reference to clean up later
const listeners = [];
listeners.push(onSnapshot(ref1, cb1));
listeners.push(onSnapshot(ref2, cb2));
// Clean up all (e.g. onUnmount in React)
function cleanup() {
listeners.forEach((unsub) => unsub());
listeners.length = 0;
}
// React: useEffect(() => { return unsub; }, []);Each onSnapshot() counts the an active read. Whenever the component is destroyed, call unsub() to avoid memory leaks and unnecessary reads.
Error Handling
const unsub = onSnapshot(ref, {
next: (snap) => {
console.log("Data:", snap.data());
},
error: (err) => {
console.error("Listener error:", err.code, err.message);
// permission-denied, unavailable, etc.
},
});Pass an object with next and error instead of a simple callback. Common errors: permission-denied (rules) and unavailable (network).
Offline and Persistence
import { initializeFirestore, persistentLocalCache } from "firebase/firestore";
// Enable persistent cache (IndexedDB)
const db = initializeFirestore(app, {
localCache: persistentLocalCache({ tabManager: persistentMultipleTabManager() }),
});
// Offline write → syncs when the network is back
await setDoc(doc(db, "posts", "p1"), { title: "Offline" });With persistentLocalCache(), data survives reloads. Offline writes are stored locally and synced automatically when the connection comes back.
Authentication
Sign Up (Email/Password)
import { getAuth, createUserWithEmailAndPassword } from "firebase/auth";
const auth = getAuth();
const cred = await createUserWithEmailAndPassword(auth, email, password);
console.log("UID:", cred.user.uid);createUserWithEmailAndPassword() creates the account and signs in automatically. Returns a UserCredential with cred.user.uid. The password must be at least 6 characters.
Social Login (Google)
import { GoogleAuthProvider, signInWithPopup } from "firebase/auth";
const provider = new GoogleAuthProvider();
provider.addScope("profile");
provider.addScope("email");
const cred = await signInWithPopup(auth, provider);
console.log("Name:", cred.user.displayName);
console.log("Photo:", cred.user.photoURL);GoogleAuthProvider + signInWithPopup() opens a Google login popup. addScope() requests extra permissions. Also available: signInWithRedirect() for mobile.
Login (Email/Password)
import { signInWithEmailAndPassword } from "firebase/auth";
try {
const cred = await signInWithEmailAndPassword(auth, email, password);
console.log("Welcome:", cred.user.email);
} catch (err) {
// auth/invalid-credential (Firebase v10+)
console.error("Error:", err.code);
}signInWithEmailAndPassword() authenticates the user. Common errors: auth/invalid-credential, auth/user-not-found, auth/wrong-password.
Password Reset
import { sendPasswordResetEmail } from "firebase/auth";
await sendPasswordResetEmail(auth, "user@mail.com");
// Reset email sent
// After login, change password:
import { updatePassword } from "firebase/auth";
await updatePassword(auth.currentUser, "newPassword123");sendPasswordResetEmail() sends a recovery email. updatePassword() changes the logged-in user's password — requires a recent session (otherwise it asks to re-auth).
Authentication State
import { onAuthStateChanged } from "firebase/auth";
const unsub = onAuthStateChanged(auth, (user) => {
if (user) {
console.log("Logged in:", user.uid, user.email);
console.log("Token:", user.accessToken);
} else {
console.log("Session ended");
}
});onAuthStateChanged() is the main auth listener. It fires on page load and on every login/logout. Essential for protecting routes and showing conditional UI.
Profile Data
import { updateProfile } from "firebase/auth";
await updateProfile(auth.currentUser, {
displayName: "Anna Silva",
photoURL: "https://example.com/photo.jpg",
});
// Reload data
await auth.currentUser.reload();
console.log(auth.currentUser.displayName);updateProfile() changes displayName and photoURL. For custom data (age, bio), store it in Firestore in a document with the same uid.
Logout
import { signOut } from "firebase/auth";
await signOut(auth);
// onAuthStateChanged fires with user = nullsignOut() ends the session. The onAuthStateChanged() listener is notified automatically with user = null. Clear the app state in that callback.
Tokens and Verification
// Get the ID token (JWT) to send to APIs
const token = await auth.currentUser.getIdToken(true);
// On the server (Node.js Admin SDK):
const admin = require("firebase-admin");
const decoded = await admin.auth().verifyIdToken(token);
console.log("Verified UID:", decoded.uid);getIdToken() returns a JWT to authenticate requests to APIs. On the backend, admin.auth().verifyIdToken() validates the token and extracts the uid.
Storage
File Upload
import { getStorage, ref, uploadBytes } from "firebase/storage";
const storage = getStorage();
const fileRef = ref(storage, "photos/image.jpg");
const result = await uploadBytes(fileRef, file);
console.log("Path:", result.ref.fullPath);uploadBytes() uploads the file all at once. The first argument is the ref() with the path in the bucket, the second is the File or Blob.
List Files
import { listAll, ref } from "firebase/storage";
const result = await listAll(ref(storage, "photos/"));
result.items.forEach((item) => {
console.log("File:", item.fullPath);
});
result.prefixes.forEach((folder) => {
console.log("Subfolder:", folder.fullPath);
});listAll() returns items (files) and prefixes (subfolders). For many folders, use list() with pagination via pageToken.
Get Download URL
import { getDownloadURL, ref } from "firebase/storage";
const url = await getDownloadURL(ref(storage, "photos/image.jpg"));
// Use in HTML
imgElement.src = url;
// Or store it in Firestore
await updateDoc(docRef, { photoURL: url });getDownloadURL() returns a temporary URL with an access token. Store it in Firestore for future reference. The URL changes if the file is replaced.
Delete File
import { deleteObject, ref } from "firebase/storage";
await deleteObject(ref(storage, "photos/image.jpg"));
// Delete multiple
const paths = ["photos/a.jpg", "photos/b.jpg"];
await Promise.all(
paths.map((p) => deleteObject(ref(storage, p)))
);deleteObject() removes a file from the bucket. Throws an object-not-found error if it does not exist. Use Promise.all() to delete several in parallel.
Upload with Progress
import { uploadBytesResumable } from "firebase/storage";
const task = uploadBytesResumable(fileRef, file);
task.on("state_changed", {
next: (snap) => {
const pct = (snap.bytesTransferred / snap.totalBytes) * 100;
console.log(`${pct.toFixed(0)}%`);
},
error: (err) => console.error(err),
complete: () => console.log("Upload complete!"),
});uploadBytesResumable() lets you monitor progress and pause/resume. state_changed fires on every chunk sent. Ideal for large files.
File Metadata
import { getMetadata, updateMetadata } from "firebase/storage";
const meta = await getMetadata(fileRef);
console.log(meta.contentType, meta.size, meta.timeCreated);
// Update metadata
await updateMetadata(fileRef, {
contentType: "image/webp",
customMetadata: { author: "ana" },
});getMetadata() returns contentType, size, timeCreated. updateMetadata() lets you change the MIME type and add customMetadata.
Pause and Resume Upload
const task = uploadBytesResumable(fileRef, file); // Pause task.pause(); // Resume task.resume(); // Cancel task.cancel();
The UploadTask object has pause(), resume() and cancel() methods. Useful for slow connections or when the user navigates to another page.
String/Base64 Upload
import { uploadString } from "firebase/storage";
// Data URL (base64)
await uploadString(fileRef, "data:image/png;base64,iVBOR...", "data_url");
// Raw string
await uploadString(ref(storage, "notes.txt"), "Content here", "raw", {
contentType: "text/plain",
});uploadString() uploads strings directly. Formats: "raw", "base64", "base64url" and "data_url". Useful for canvas-generated images.
Security Rules
Basic Structure
rules_version = "2";
service cloud.firestore {
match /databases/{database}/documents {
match /users/{userId} {
allow read: if true;
allow write: if request.auth.uid == userId;
}
}
}Rules use match to define paths and allow for permissions. request.auth.uid is the authenticated user. No rule = access denied.
Rules in Subcollections
match /posts/{postId} {
allow read: if true;
match /comments/{commentId} {
allow read: if true;
allow create: if request.auth != null
&& request.resource.data.postId == postId;
}
}Subcollections inherit the context of the parent match. The {postId} variable stays accessible in the subcollection rules for cross validations.
Granular Operations
match /posts/{postId} {
allow get: if true; // read 1 doc
allow list: if true; // list collection
allow create: if request.auth != null;
allow update: if request.auth.uid == resource.data.authorId;
allow delete: if false; // nobody deletes
}read = get + list. write = create + update + delete. Using granular operations gives finer control over access.
Custom Functions
function isAdmin() {
return request.auth != null
&& request.auth.token.admin == true;
}
function isDocOwner(userId) {
return request.auth.uid == userId;
}
match /users/{userId} {
allow read: if isAdmin() || isDocOwner(userId);
allow write: if isDocOwner(userId);
}function creates reusable helpers. It reduces repetition and makes the rules more readable. They can take parameters and access request and resource.
request vs resource
allow update: if // Data the client wants to write request.resource.data.title is string && request.resource.data.title.size() > 0 // Current data on the server && resource.data.authorId == request.auth.uid;
request.resource.data is the new data (to be written). resource.data is the current data on the server. Compare both to validate changes.
get() to Check Other Docs
match /posts/{postId} {
allow create: if
request.auth != null
&& get(/databases/$(database)/documents/users/$(request.auth.uid))
.data.plan == "premium";
}get() reads another document during rule evaluation. Useful to check profiles, subscriptions or permissions stored elsewhere. It has a read cost.
Validate Data Types
allow create: if
request.resource.data.name is string
&& request.resource.data.age is int
&& request.resource.data.age >= 0
&& request.resource.data.email is string
&& request.resource.data.email.matches(".*@.*\..*")
&& request.resource.data.keys().hasAll(["name", "email"]);Validate types with is string, is int, is bool. matches() applies a regex. keys().hasAll() guarantees required fields.
Storage Rules
service firebase.storage {
match /b/{bucket}/o {
match /photos/{userId}/{fileName} {
allow read: if true;
allow write: if request.auth.uid == userId
&& request.resource.size < 5 * 1024 * 1024
&& request.resource.contentType.matches("image/.*");
}
}
}Storage rules use request.resource.size (bytes) and request.resource.contentType. Limit file size and type for security.
Advanced
Batch Writes
import { writeBatch, doc } from "firebase/firestore";
const batch = writeBatch(db);
batch.set(doc(db, "users", "u1"), { name: "Anna" });
batch.update(doc(db, "users", "u2"), { active: false });
batch.delete(doc(db, "temp", "x"));
await batch.commit(); // all or nothingwriteBatch() groups up to 500 operations (set, update, delete) into a single atomic write. Either all succeed or none is applied. It does not allow reads.
Composite Indexes
// firestore.indexes.json
{
"indexes": [{
"collectionGroup": "posts",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "author", "order": "ASCENDING" },
{ "fieldPath": "created", "order": "DESCENDING" }
]
}]
}Queries with where + orderBy on different fields require composite indexes. Define them in firestore.indexes.json and publish with firebase deploy.
Transactions (runTransaction)
import { runTransaction, doc } from "firebase/firestore";
await runTransaction(db, async (tx) => {
const snap = await tx.get(doc(db, "accounts", "c1"));
if (!snap.exists()) throw "Account does not exist";
const newBalance = snap.data().balance - 50;
if (newBalance < 0) throw "Insufficient balance";
tx.update(doc(db, "accounts", "c1"), { balance: newBalance });
});runTransaction() allows atomic read + write. If the data changes during execution, Firestore retries automatically (up to 5 attempts).
Local Emulators
// firebase.json
{
"emulators": {
"firestore": { "port": 8080 },
"auth": { "port": 9099 },
"storage": { "port": 9199 },
"ui": { "enabled": true, "port": 4000 }
}
}
// Connect the SDK to the emulator
import { connectFirestoreEmulator } from "firebase/firestore";
connectFirestoreEmulator(db, "localhost", 8080);The emulators simulate Firestore, Auth and Storage locally at no cost. connectFirestoreEmulator() redirects the SDK. Debug UI at localhost:4000.
Cloud Functions (Triggers)
// functions/index.js
const { onDocumentCreated } = require("firebase-functions/v2/firestore");
exports.onNewUser = onDocumentCreated("users/{userId}", (event) => {
const data = event.data.data();
console.log("New user:", data.email);
// Send welcome email, create profile doc, etc.
});Cloud Functions runs code on the server in response to events. Triggers: onDocumentCreated, onDocumentUpdated, onDocumentDeleted, onDocumentWritten.
Security (Best Practices)
// ❌ NEVER in production: allow read, write: if true; // ✅ Always authenticate: allow read: if request.auth != null; // ✅ Validate input data: allow create: if request.resource.data.keys().hasAll(["name"]); // ✅ Restrict per user: allow write: if request.auth.uid == userId;
Never use allow read, write: if true in production. Always authenticate with request.auth, validate input data and restrict access by uid.
Admin SDK (Server)
const admin = require("firebase-admin");
admin.initializeApp();
const db = admin.firestore();
// Create
await db.collection("users").doc("u1").set({ name: "Anna" });
// Query
const snap = await db.collection("users").where("active", "==", true).get();
// Delete an entire collection
const docs = await db.collection("temp").listDocuments();
await Promise.all(docs.map(d => d.delete()));The Admin SDK runs on the server without security rules. Use admin.firestore() instead of getFirestore(). Ideal for administrative tasks and migrations.
Limits and Quotas
// Main Firestore limits: // - Document: max. 1 MB // - Subcollection depth: 100 levels // - Batch: max. 500 operations // - Fields per document: no practical limit // - Writes: ~10k writes/sec per DB // - Free reads: 50k/day (Spark plan)
Know the limits: documents up to 1 MB, batches up to 500 ops, subcollections up to 100 levels. The free plan (Spark) gives 50k reads and 20k writes per day.