home / skills / jezweb / claude-skills / firebase-firestore
/skills/firebase-firestore
This skill helps you manage Firestore databases with client and admin SDKs, covering CRUD, real-time listeners, security rules, and indexing.
npx playbooks add skill jezweb/claude-skills --skill firebase-firestoreReview the files below or copy the command above to add this skill to your agents.
---
name: firebase-firestore
description: |
Build with Firestore NoSQL database - real-time sync, offline support, and scalable document storage. Use when: creating collections, querying documents, setting up security rules, handling real-time listeners, or troubleshooting permission-denied, quota exceeded, invalid query, or offline persistence errors. Prevents 10 documented errors.
user-invocable: true
---
# Firebase Firestore Database
**Status**: Production Ready
**Last Updated**: 2026-01-25
**Dependencies**: None (standalone skill)
**Latest Versions**: [email protected], [email protected]
---
## Quick Start (5 Minutes)
### 1. Install Firebase SDK
```bash
# Client SDK (web/mobile)
npm install firebase
# Admin SDK (server/backend)
npm install firebase-admin
```
### 2. Initialize Firebase (Client)
```typescript
// src/lib/firebase.ts
import { initializeApp } from 'firebase/app';
import { getFirestore } from 'firebase/firestore';
const firebaseConfig = {
apiKey: process.env.FIREBASE_API_KEY,
authDomain: process.env.FIREBASE_AUTH_DOMAIN,
projectId: process.env.FIREBASE_PROJECT_ID,
storageBucket: process.env.FIREBASE_STORAGE_BUCKET,
messagingSenderId: process.env.FIREBASE_MESSAGING_SENDER_ID,
appId: process.env.FIREBASE_APP_ID,
};
const app = initializeApp(firebaseConfig);
export const db = getFirestore(app);
```
### 3. Initialize Firebase Admin (Server)
```typescript
// src/lib/firebase-admin.ts
import { initializeApp, cert, getApps } from 'firebase-admin/app';
import { getFirestore } from 'firebase-admin/firestore';
// Initialize only once
if (!getApps().length) {
initializeApp({
credential: cert({
projectId: process.env.FIREBASE_PROJECT_ID,
clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
// Replace escaped newlines in private key
privateKey: process.env.FIREBASE_PRIVATE_KEY?.replace(/\\n/g, '\n'),
}),
});
}
export const adminDb = getFirestore();
```
**CRITICAL:**
- Never expose `FIREBASE_PRIVATE_KEY` in client code
- Use Admin SDK for server-side operations (bypasses security rules)
- Use Client SDK for authenticated user operations
---
## Core Operations
### Document CRUD (Client SDK - Modular v9+)
```typescript
import {
collection,
doc,
addDoc,
getDoc,
getDocs,
setDoc,
updateDoc,
deleteDoc,
query,
where,
orderBy,
limit,
serverTimestamp,
Timestamp,
} from 'firebase/firestore';
import { db } from './firebase';
// CREATE - Auto-generated ID
const docRef = await addDoc(collection(db, 'users'), {
name: 'John Doe',
email: '[email protected]',
createdAt: serverTimestamp(),
});
console.log('Created document with ID:', docRef.id);
// CREATE - Specific ID
await setDoc(doc(db, 'users', 'user-123'), {
name: 'Jane Doe',
email: '[email protected]',
createdAt: serverTimestamp(),
});
// READ - Single document
const docSnap = await getDoc(doc(db, 'users', 'user-123'));
if (docSnap.exists()) {
console.log('Document data:', docSnap.data());
} else {
console.log('No such document!');
}
// READ - Collection with query
const q = query(
collection(db, 'users'),
where('email', '==', '[email protected]'),
orderBy('createdAt', 'desc'),
limit(10)
);
const querySnapshot = await getDocs(q);
querySnapshot.forEach((doc) => {
console.log(doc.id, ' => ', doc.data());
});
// UPDATE - Merge fields (doesn't overwrite entire document)
await updateDoc(doc(db, 'users', 'user-123'), {
name: 'Jane Smith',
updatedAt: serverTimestamp(),
});
// UPDATE - Set with merge (creates if doesn't exist)
await setDoc(doc(db, 'users', 'user-123'), {
lastLogin: serverTimestamp(),
}, { merge: true });
// DELETE
await deleteDoc(doc(db, 'users', 'user-123'));
```
### Document CRUD (Admin SDK)
```typescript
import { adminDb } from './firebase-admin';
import { FieldValue, Timestamp } from 'firebase-admin/firestore';
// CREATE
const docRef = await adminDb.collection('users').add({
name: 'John Doe',
createdAt: FieldValue.serverTimestamp(),
});
// CREATE with specific ID
await adminDb.collection('users').doc('user-123').set({
name: 'Jane Doe',
createdAt: FieldValue.serverTimestamp(),
});
// READ
const doc = await adminDb.collection('users').doc('user-123').get();
if (doc.exists) {
console.log('Document data:', doc.data());
}
// READ with query
const snapshot = await adminDb
.collection('users')
.where('email', '==', '[email protected]')
.orderBy('createdAt', 'desc')
.limit(10)
.get();
snapshot.forEach((doc) => {
console.log(doc.id, '=>', doc.data());
});
// UPDATE
await adminDb.collection('users').doc('user-123').update({
name: 'Jane Smith',
updatedAt: FieldValue.serverTimestamp(),
});
// DELETE
await adminDb.collection('users').doc('user-123').delete();
```
---
## Real-Time Listeners (Client SDK)
```typescript
import { onSnapshot, query, where, collection, doc } from 'firebase/firestore';
import { db } from './firebase';
// Listen to single document
const unsubscribe = onSnapshot(doc(db, 'users', 'user-123'), (doc) => {
if (doc.exists()) {
console.log('Current data:', doc.data());
}
});
// Listen to collection with query
const q = query(
collection(db, 'messages'),
where('roomId', '==', 'room-123'),
orderBy('createdAt', 'desc'),
limit(50)
);
const unsubscribeMessages = onSnapshot(q, (querySnapshot) => {
const messages: Message[] = [];
querySnapshot.forEach((doc) => {
messages.push({ id: doc.id, ...doc.data() } as Message);
});
// Update UI with messages
setMessages(messages);
});
// Handle errors
const unsubscribeWithError = onSnapshot(
doc(db, 'users', 'user-123'),
(doc) => {
// Handle updates
},
(error) => {
console.error('Listener error:', error);
// Handle permission denied, etc.
}
);
// IMPORTANT: Unsubscribe when done (React useEffect cleanup)
useEffect(() => {
const unsubscribe = onSnapshot(/* ... */);
return () => unsubscribe();
}, []);
```
**CRITICAL:**
- Always unsubscribe from listeners to prevent memory leaks
- Listeners count against your concurrent connection limit
- Each listener is a WebSocket connection
---
## Query Patterns
### Compound Queries
```typescript
import { query, where, orderBy, limit, startAfter, collection } from 'firebase/firestore';
// Multiple where clauses (requires composite index)
const q = query(
collection(db, 'products'),
where('category', '==', 'electronics'),
where('price', '<=', 1000),
orderBy('price', 'asc')
);
// Range query (only one field can have inequality)
const rangeQuery = query(
collection(db, 'events'),
where('date', '>=', new Date('2025-01-01')),
where('date', '<=', new Date('2025-12-31')),
orderBy('date', 'asc')
);
// Array contains
const arrayQuery = query(
collection(db, 'posts'),
where('tags', 'array-contains', 'firebase')
);
// Array contains any (max 30 values)
const arrayAnyQuery = query(
collection(db, 'posts'),
where('tags', 'array-contains-any', ['firebase', 'google', 'cloud'])
);
// In query (max 30 values)
const inQuery = query(
collection(db, 'users'),
where('status', 'in', ['active', 'pending'])
);
// Not in query (max 10 values)
const notInQuery = query(
collection(db, 'users'),
where('status', 'not-in', ['banned', 'deleted'])
);
```
### Pagination with Cursors
```typescript
import { query, orderBy, limit, startAfter, getDocs, collection, DocumentSnapshot } from 'firebase/firestore';
let lastVisible: DocumentSnapshot | null = null;
async function getNextPage() {
let q = query(
collection(db, 'posts'),
orderBy('createdAt', 'desc'),
limit(10)
);
if (lastVisible) {
q = query(q, startAfter(lastVisible));
}
const snapshot = await getDocs(q);
// Save last document for next page
lastVisible = snapshot.docs[snapshot.docs.length - 1] || null;
return snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() }));
}
```
### Collection Group Queries
```typescript
import { collectionGroup, query, where, getDocs } from 'firebase/firestore';
// Query across all subcollections named 'comments'
// Structure: posts/{postId}/comments/{commentId}
const q = query(
collectionGroup(db, 'comments'),
where('authorId', '==', 'user-123')
);
const snapshot = await getDocs(q);
// Returns all comments by user-123 across all posts
```
**CRITICAL:** Collection group queries require an index. Create in Firebase Console or deploy via `firestore.indexes.json`.
---
## Batch Operations & Transactions
### Batch Writes (Up to 500 operations)
```typescript
import { writeBatch, doc, collection, serverTimestamp } from 'firebase/firestore';
import { db } from './firebase';
const batch = writeBatch(db);
// Add multiple documents
const usersRef = collection(db, 'users');
batch.set(doc(usersRef), { name: 'User 1', createdAt: serverTimestamp() });
batch.set(doc(usersRef), { name: 'User 2', createdAt: serverTimestamp() });
// Update existing document
batch.update(doc(db, 'counters', 'users'), { total: 100 });
// Delete document
batch.delete(doc(db, 'temp', 'old-doc'));
// Commit all operations atomically
await batch.commit();
```
### Transactions (Read then Write)
```typescript
import { runTransaction, doc, increment } from 'firebase/firestore';
import { db } from './firebase';
// Transfer credits between users
async function transferCredits(fromId: string, toId: string, amount: number) {
await runTransaction(db, async (transaction) => {
const fromRef = doc(db, 'users', fromId);
const toRef = doc(db, 'users', toId);
const fromDoc = await transaction.get(fromRef);
const toDoc = await transaction.get(toRef);
if (!fromDoc.exists() || !toDoc.exists()) {
throw new Error('User not found');
}
const fromCredits = fromDoc.data().credits;
if (fromCredits < amount) {
throw new Error('Insufficient credits');
}
transaction.update(fromRef, { credits: fromCredits - amount });
transaction.update(toRef, { credits: increment(amount) });
});
}
```
**CRITICAL:**
- Transactions can fail and retry automatically (up to 5 times)
- Don't perform side effects inside transaction (may run multiple times)
- All reads must come before writes in a transaction
---
## Security Rules
### Basic Rules Structure
```javascript
// firestore.rules
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// Helper functions
function isAuthenticated() {
return request.auth != null;
}
function isOwner(userId) {
return request.auth.uid == userId;
}
function isValidUser() {
return request.resource.data.keys().hasAll(['name', 'email'])
&& request.resource.data.name is string
&& request.resource.data.email is string;
}
// Users collection
match /users/{userId} {
allow read: if isAuthenticated();
allow create: if isAuthenticated() && isOwner(userId) && isValidUser();
allow update: if isOwner(userId);
allow delete: if isOwner(userId);
}
// Posts collection with subcollections
match /posts/{postId} {
allow read: if resource.data.published == true || isOwner(resource.data.authorId);
allow create: if isAuthenticated() && request.resource.data.authorId == request.auth.uid;
allow update, delete: if isOwner(resource.data.authorId);
// Comments subcollection
match /comments/{commentId} {
allow read: if true;
allow create: if isAuthenticated();
allow update, delete: if isOwner(resource.data.authorId);
}
}
// Admin-only collection
match /admin/{document=**} {
allow read, write: if request.auth.token.admin == true;
}
}
}
```
### Deploy Rules
```bash
# Deploy rules
firebase deploy --only firestore:rules
# Deploy rules and indexes
firebase deploy --only firestore
```
---
## Indexes
### Composite Indexes (firestore.indexes.json)
```json
{
"indexes": [
{
"collectionGroup": "products",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "category", "order": "ASCENDING" },
{ "fieldPath": "price", "order": "ASCENDING" }
]
},
{
"collectionGroup": "comments",
"queryScope": "COLLECTION_GROUP",
"fields": [
{ "fieldPath": "authorId", "order": "ASCENDING" },
{ "fieldPath": "createdAt", "order": "DESCENDING" }
]
}
],
"fieldOverrides": []
}
```
```bash
# Deploy indexes
firebase deploy --only firestore:indexes
```
**CRITICAL:**
- Firestore auto-creates single-field indexes
- Composite indexes must be created manually or via error link
- Collection group queries always require an index
---
## Offline Persistence
### Enable Offline Support (Web)
```typescript
import { initializeFirestore, persistentLocalCache, persistentMultipleTabManager } from 'firebase/firestore';
import { app } from './firebase';
// Enable multi-tab offline persistence
const db = initializeFirestore(app, {
localCache: persistentLocalCache({
tabManager: persistentMultipleTabManager()
})
});
// OR: Enable single-tab persistence (simpler)
import { enableIndexedDbPersistence, getFirestore } from 'firebase/firestore';
const db = getFirestore(app);
enableIndexedDbPersistence(db).catch((err) => {
if (err.code === 'failed-precondition') {
// Multiple tabs open, persistence can only be enabled in one tab
console.warn('Persistence failed: multiple tabs open');
} else if (err.code === 'unimplemented') {
// Browser doesn't support persistence
console.warn('Persistence not supported');
}
});
```
### Handle Offline State
```typescript
import { onSnapshot, doc, SnapshotMetadata } from 'firebase/firestore';
onSnapshot(doc(db, 'users', 'user-123'), (doc) => {
const source = doc.metadata.fromCache ? 'local cache' : 'server';
console.log(`Data came from ${source}`);
if (doc.metadata.hasPendingWrites) {
console.log('Local changes pending sync');
}
});
```
---
## Data Modeling Best Practices
### Denormalization for Read Performance
```typescript
// Instead of joining users and posts...
// Store author info directly in post document
// posts/{postId}
{
title: 'My Post',
content: '...',
authorId: 'user-123',
// Denormalized author data for fast reads
author: {
name: 'John Doe',
avatarUrl: 'https://...'
},
createdAt: Timestamp
}
```
### Subcollections vs Root Collections
```typescript
// Subcollections: Good for parent-child relationships
// posts/{postId}/comments/{commentId}
// - Easy to query all comments for a post
// - Deleting post doesn't auto-delete comments (use Cloud Functions)
// Root collections: Good for cross-cutting queries
// comments (with postId field)
// - Easy to query all comments by a user across posts
// - Requires manual data consistency
```
### Counter Pattern (High-Write Scenarios)
```typescript
// Direct increment (low traffic)
await updateDoc(doc(db, 'posts', postId), {
viewCount: increment(1)
});
// Distributed counter (high traffic - 1000+ writes/sec)
// Use Cloud Functions to aggregate shard counts
// counters/{counterId}/shards/{shardId}
```
---
## Error Handling
### Common Errors and Solutions
| Error | Cause | Solution |
|-------|-------|----------|
| `permission-denied` | Security rules blocking access | Check rules, ensure user authenticated |
| `not-found` | Document doesn't exist | Use `exists()` check before accessing data |
| `already-exists` | Document with ID already exists | Use `setDoc` with merge or generate new ID |
| `resource-exhausted` | Quota exceeded | Upgrade plan or optimize queries |
| `failed-precondition` | Index missing for query | Create composite index (link in error) |
| `unavailable` | Service temporarily unavailable | Implement retry with backoff |
| `invalid-argument` | Invalid query combination | Check query constraints (see below) |
| `deadline-exceeded` | Operation timeout | Reduce data size or paginate |
### Query Constraints
```typescript
// INVALID: Multiple inequality filters on different fields
query(collection(db, 'posts'),
where('date', '>', startDate),
where('likes', '>', 100) // ERROR: Can't use inequality on second field
);
// VALID: Use range on one field, equality on others
query(collection(db, 'posts'),
where('category', '==', 'tech'),
where('date', '>', startDate)
);
// INVALID: orderBy field different from inequality field
query(collection(db, 'posts'),
where('date', '>', startDate),
orderBy('likes') // ERROR: Must orderBy('date') first
);
// VALID: orderBy inequality field first
query(collection(db, 'posts'),
where('date', '>', startDate),
orderBy('date'),
orderBy('likes')
);
```
---
## Known Issues Prevention
This skill prevents **10** documented Firestore errors:
| Issue # | Error/Issue | Description | How to Avoid | Source |
|---------|-------------|-------------|--------------|--------|
| **#1** | `permission-denied` | Security rules blocking operation | Test rules in Firebase Console emulator first | Common |
| **#2** | `failed-precondition` (index) | Composite index missing | Click error link to create index, or define in firestore.indexes.json | Common |
| **#3** | Invalid query combination | Multiple inequality filters | Use inequality on one field only, equality on others | [Docs](https://firebase.google.com/docs/firestore/query-data/queries#query_limitations) |
| **#4** | Memory leak from listeners | Not unsubscribing from onSnapshot | Always call unsubscribe in cleanup (useEffect return) | Common |
| **#5** | Offline persistence conflict | Multiple tabs with persistence | Use `persistentMultipleTabManager()` or handle error | [Docs](https://firebase.google.com/docs/firestore/manage-data/enable-offline) |
| **#6** | Transaction side effects | Side effects run multiple times | Never perform side effects inside runTransaction | [Docs](https://firebase.google.com/docs/firestore/manage-data/transactions) |
| **#7** | Batch limit exceeded | More than 500 operations | Split into multiple batches | [Docs](https://firebase.google.com/docs/firestore/manage-data/transactions#batched-writes) |
| **#8** | `resource-exhausted` | Quota limits hit | Implement pagination, reduce reads, use caching | Common |
| **#9** | Private key newline issue | `\\n` not converted in env var | Use `.replace(/\\n/g, '\n')` on private key | Common |
| **#10** | Collection group query fails | Missing collection group index | Create index with `queryScope: COLLECTION_GROUP` | [Docs](https://firebase.google.com/docs/firestore/query-data/queries#collection-group-query) |
---
## Firebase CLI Commands
```bash
# Initialize Firestore
firebase init firestore
# Start emulators
firebase emulators:start --only firestore
# Deploy rules and indexes
firebase deploy --only firestore
# Export data (for backup)
gcloud firestore export gs://your-bucket/backups/$(date +%Y%m%d)
# Import data
gcloud firestore import gs://your-bucket/backups/20250125
```
---
## Package Versions (Verified 2026-01-25)
```json
{
"dependencies": {
"firebase": "^12.8.0"
},
"devDependencies": {
"firebase-admin": "^13.6.0"
}
}
```
---
## Official Documentation
- **Firestore Overview**: https://firebase.google.com/docs/firestore
- **Get Started**: https://firebase.google.com/docs/firestore/quickstart
- **Data Model**: https://firebase.google.com/docs/firestore/data-model
- **Security Rules**: https://firebase.google.com/docs/firestore/security/get-started
- **Query Data**: https://firebase.google.com/docs/firestore/query-data/queries
- **Manage Data**: https://firebase.google.com/docs/firestore/manage-data/add-data
- **Offline Data**: https://firebase.google.com/docs/firestore/manage-data/enable-offline
---
**Last verified**: 2026-01-25 | **Skill version**: 1.0.0
This skill provides reusable TypeScript patterns and recipes for building with Firebase Firestore: real-time sync, offline persistence, security rules, indexing, batch writes, and transactions. It targets both client (firebase v12+) and server (firebase-admin) usage and prevents common errors like permission-denied, invalid queries, and offline persistence failures. Use it to accelerate safe, scalable Firestore integration in web and server apps.
The skill supplies initialization snippets for client and admin SDKs, modular CRUD examples, real-time listener patterns, query & pagination techniques, collection group queries, batch/transaction code, index configuration, offline persistence setup, and security rule templates. It highlights critical operational constraints (indexes, listener counts, private key handling) and provides code to detect and handle common Firestore errors and edge cases.
When should I use the Admin SDK vs the Client SDK?
Use the Admin SDK for server-side, privileged operations (bypasses rules). Use the Client SDK for authenticated user interactions and to enforce security rules.
How do I fix index-related query errors?
Follow the console error link to create the required composite index or declare indexes in firestore.indexes.json and deploy with firebase deploy --only firestore:indexes.