JSON in Firebase: Firestore Documents, Realtime DB, and TypeScript
Last updated:
Firebase stores data as JSON-like documents in two databases: Firestore (a NoSQL document database with collections, subcollections, and real-time listeners) and the Realtime Database (a single JSON tree synced across all clients). Both accept plain JavaScript objects and return them as-is — no ORM mapping layer needed. Firestore documents are limited to 1 MB and support native types beyond JSON: Timestamp, GeoPoint, DocumentReference, and Bytes. When you read a Firestore document with getDoc(), it returns a DocumentSnapshot — call .data() to get the plain JavaScript object. Realtime Database stores data as a raw JSON tree: db.ref('users/123').set({ name: 'Alice' }) writes directly, db.ref('users/123').once('value') reads back. TypeScript support requires either manual interface definitions or withConverter() for typed Firestore reads and writes. This guide covers Firestore document structure, withConverter() for type-safe CRUD, Zod validation on reads, Realtime Database JSON patterns, and Cloud Functions JSON request/response handling.
Firestore Document Structure and JSON Mapping
Bottom line: Firestore documents are JSON-like objects that also support native types — Timestamp, GeoPoint, DocumentReference, and Bytes — that plain JSON does not have. Reading a document with getDoc() returns a DocumentSnapshot; call .data() to get the JavaScript object. Writing uses setDoc(), addDoc(), or updateDoc().
The key operational difference from plain JSON is the Timestamp type. When you read a Firestore document that has a Timestamp field, .data() gives you a Timestamp object with seconds and nanoseconds properties — not a string or a JavaScript Date. Calling JSON.stringify on the result serializes it as {"seconds":1234567890,"nanoseconds":0}, which loses the type information. Always call .toDate().toISOString() before passing Timestamp fields to JSON.stringify. A single Firestore document can hold up to 1 MB of data and 20,000 fields; arrays inside a document count toward this field limit.
import {
getFirestore, doc, collection,
getDoc, setDoc, addDoc, updateDoc,
Timestamp, GeoPoint, serverTimestamp,
} from "firebase/firestore"
import { initializeApp } from "firebase/app"
const app = initializeApp({ /* your config */ })
const db = getFirestore(app)
// --- Firestore document shape: JSON-like with native types ---
// {
// name: "Alice", // string
// age: 30, // number
// active: true, // boolean
// tags: ["admin", "beta"], // array
// address: { city: "NYC", zip: "10001" }, // map (nested object)
// createdAt: Timestamp, // Firestore-native (not JSON)
// location: GeoPoint, // Firestore-native (not JSON)
// deletedAt: null, // null
// }
// --- setDoc: write a full document (creates or overwrites) ---
await setDoc(doc(db, "users", "alice123"), {
name: "Alice",
age: 30,
active: true,
tags: ["admin", "beta"],
address: { city: "NYC", zip: "10001" },
createdAt: serverTimestamp(), // server-set Timestamp
location: new GeoPoint(40.7128, -74.006),
deletedAt: null,
})
// --- addDoc: write with auto-generated ID ---
const ref = await addDoc(collection(db, "posts"), {
title: "Hello Firestore",
body: "Content here",
publishedAt: Timestamp.now(),
})
console.log("New doc ID:", ref.id)
// --- updateDoc: partial update (only listed fields change) ---
await updateDoc(doc(db, "users", "alice123"), {
age: 31,
"address.city": "Brooklyn", // dot-notation for nested field
})
// --- getDoc: read a document ---
const snap = await getDoc(doc(db, "users", "alice123"))
if (snap.exists()) {
const data = snap.data()
console.log(data.name) // "Alice"
console.log(data.createdAt) // Timestamp { seconds: ..., nanoseconds: ... }
// ⚠️ Serialization warning: Timestamp is NOT a plain JSON value
// JSON.stringify(data) → {"name":"Alice","createdAt":{"seconds":...}}
// Correct approach: convert Timestamp before serializing
const serializable = {
...data,
createdAt: data.createdAt?.toDate().toISOString(),
}
console.log(JSON.stringify(serializable)) // {"name":"Alice","createdAt":"2026-05-27T..."}
}Type-Safe CRUD with withConverter()
Bottom line: withConverter() is Firestore's built-in type adapter. Apply it to a collection or document reference, and every read returns your TypeScript interface instead of the generic DocumentData — no type casting needed. It must be applied to each reference individually.
A FirestoreDataConverter<T> requires two methods: toFirestore(data: T) converts your object to Firestore's DocumentData before writing, and fromFirestore(snapshot) converts a QueryDocumentSnapshot back to T after reading. The fromFirestore method should handle missing or undefined fields gracefully — use ?? defaultValue or optional chaining to avoid runtime errors when reading older documents that pre-date a schema change. Apply the converter to a collection reference for addDoc() and getDocs(), or to a document reference for getDoc() and setDoc(). The converter is not global; if you create a new reference without withConverter(), the type reverts to DocumentData.
import {
getFirestore, doc, collection,
getDoc, setDoc, addDoc, getDocs, query, where,
Timestamp, FirestoreDataConverter, QueryDocumentSnapshot,
} from "firebase/firestore"
// --- TypeScript interface for our document ---
interface User {
id?: string
name: string
email: string
plan: "free" | "pro" | "enterprise"
createdAt: Date // we convert Timestamp → Date in fromFirestore
score: number
}
// --- FirestoreDataConverter<User> ---
const userConverter: FirestoreDataConverter<User> = {
toFirestore(user: User) {
return {
name: user.name,
email: user.email,
plan: user.plan,
// Convert Date back to Timestamp for Firestore storage
createdAt: Timestamp.fromDate(user.createdAt),
score: user.score,
}
},
fromFirestore(snapshot: QueryDocumentSnapshot): User {
const data = snapshot.data()
return {
id: snapshot.id,
name: data.name ?? "",
email: data.email ?? "",
plan: data.plan ?? "free",
// Convert Timestamp → Date; handle missing field gracefully
createdAt: data.createdAt instanceof Timestamp
? data.createdAt.toDate()
: new Date(),
score: data.score ?? 0,
}
},
}
const db = getFirestore()
// --- Apply converter to a collection reference ---
const usersCol = collection(db, "users").withConverter(userConverter)
// --- addDoc: TypeScript checks that the object matches User ---
const newRef = await addDoc(usersCol, {
name: "Bob",
email: "bob@example.com",
plan: "pro",
createdAt: new Date(),
score: 100,
})
// --- getDoc with converter applied to doc reference ---
const userDocRef = doc(db, "users", newRef.id).withConverter(userConverter)
const snap = await getDoc(userDocRef)
if (snap.exists()) {
const user = snap.data() // type: User — no casting needed
console.log(user.name) // "Bob"
console.log(user.createdAt instanceof Date) // true — Timestamp was converted
console.log(user.plan) // "pro"
}
// --- getDocs with where query ---
const q = query(usersCol, where("plan", "==", "pro"))
const snapshot = await getDocs(q)
snapshot.forEach(docSnap => {
const user = docSnap.data() // type: User
console.log(user.email, user.score)
})
// --- setDoc (full overwrite with type checking) ---
await setDoc(userDocRef, {
name: "Bob Updated",
email: "bob@example.com",
plan: "enterprise",
createdAt: new Date(),
score: 250,
})Zod Validation for Firestore Data
Bottom line: withConverter() types are compile-time only. For runtime validation — critical when reading data written by older app versions or other clients — use Zod inside fromFirestore. A Zod schema also serves as the single source of truth for your TypeScript type.
Define a Zod schema for the Firestore document shape. In fromFirestore, call UserSchema.parse(snapshot.data()) for strict validation that throws a ZodError on mismatch, or UserSchema.safeParse(snapshot.data()) for graceful degradation. With safeParse, log the error and return null instead of crashing — then filter nulls at the call site. Use z.infer<typeof UserSchema> to derive the TypeScript type from the schema automatically: no separate interface needed, and the type stays in sync with the schema as you add or remove fields. This pattern catches 3 categories of problems at runtime: missing required fields from old documents, unexpected field types from bugs in other clients, and schema drift between app versions.
import { z } from "zod"
import {
FirestoreDataConverter, QueryDocumentSnapshot, Timestamp,
getFirestore, doc, getDoc, collection, getDocs,
} from "firebase/firestore"
// --- Zod schema for a User document ---
// Use z.infer<> to derive TypeScript type — no separate interface needed
const UserSchema = z.object({
id: z.string().optional(),
name: z.string().min(1),
email: z.string().email(),
plan: z.enum(["free", "pro", "enterprise"]),
createdAt: z.date(), // we convert Timestamp → Date before parsing
score: z.number().int().min(0),
// Optional field — missing in old documents is OK
avatarUrl: z.string().url().optional(),
})
type User = z.infer<typeof UserSchema>
// --- Converter with Zod validation in fromFirestore ---
const userConverter: FirestoreDataConverter<User | null> = {
toFirestore(user: User | null) {
if (!user) return {}
return {
name: user.name,
email: user.email,
plan: user.plan,
createdAt: Timestamp.fromDate(user.createdAt),
score: user.score,
avatarUrl: user.avatarUrl ?? null,
}
},
fromFirestore(snapshot: QueryDocumentSnapshot): User | null {
const raw = snapshot.data()
// Normalize: convert Timestamp to Date before Zod parses
const normalized = {
...raw,
id: snapshot.id,
createdAt: raw.createdAt instanceof Timestamp
? raw.createdAt.toDate()
: undefined,
}
// safeParse: validate without throwing
const result = UserSchema.safeParse(normalized)
if (!result.success) {
// Log field-level errors for debugging schema drift
console.error(
`Firestore doc ${snapshot.id} failed validation:`,
result.error.flatten().fieldErrors
)
return null // graceful degradation — caller must handle null
}
return result.data
},
}
// --- Use the validated converter ---
const db = getFirestore()
const usersCol = collection(db, "users").withConverter(userConverter)
// --- Read and filter nulls (failed validation) ---
const allDocs = await getDocs(usersCol)
const validUsers: User[] = allDocs.docs
.map(d => d.data())
.filter((u): u is User => u !== null)
console.log(`Loaded ${validUsers.length} valid users`)
// --- Strict parse for critical paths (throws on bad data) ---
async function getUserStrict(id: string): Promise<User> {
const snap = await getDoc(doc(db, "users", id).withConverter({
toFirestore: userConverter.toFirestore,
fromFirestore(snapshot: QueryDocumentSnapshot): User {
const raw = snapshot.data()
const normalized = {
...raw,
id: snapshot.id,
createdAt: raw.createdAt instanceof Timestamp ? raw.createdAt.toDate() : undefined,
}
return UserSchema.parse(normalized) // throws ZodError if invalid
},
}))
if (!snap.exists()) throw new Error(`User ${id} not found`)
return snap.data()
}Realtime Database JSON Patterns
Bottom line: Realtime Database is a single JSON tree. All operations are path-based. snapshot.val() returns the JavaScript value at that path, or null if the node doesn't exist. Keep data flat — deep nesting forces large reads because Realtime Database always returns the entire subtree.
Realtime Database has a 32-level nesting limit and no server-side query filtering beyond simple ordering. The standard design pattern is denormalization: store the same data at multiple paths so each read fetches exactly what it needs. The atomic multi-path update() call (fan-out) lets you write to multiple paths in one operation — either all writes succeed or all fail. set() overwrites the entire subtree at a path; use update() for partial updates. get() is a one-time read; onValue() sets up a real-time listener that fires whenever the data changes, with the first call returning the current value immediately. Call off() or the returned unsubscribe function to stop listening and prevent memory leaks.
import { getDatabase, ref, set, get, update, onValue, off } from "firebase/database"
const db = getDatabase()
// --- set(): write (overwrites entire path) ---
await set(ref(db, "users/alice123"), {
name: "Alice",
email: "alice@example.com",
status: "online",
})
// --- get(): one-time read ---
const snap = await get(ref(db, "users/alice123"))
if (snap.exists()) {
const user = snap.val() // plain JavaScript object
console.log(user.name) // "Alice"
} else {
console.log("No data at this path")
}
// --- update(): partial update (only listed keys change) ---
await update(ref(db, "users/alice123"), {
status: "away", // only status changes; name and email preserved
})
// --- onValue(): real-time listener ---
const userRef = ref(db, "users/alice123")
const unsubscribe = onValue(userRef, (snapshot) => {
const data = snapshot.val()
if (data) {
console.log("Live update:", data.status)
}
})
// Stop listening when done (e.g., component unmounts):
// unsubscribe()
// --- Flat data design (avoid deep nesting) ---
// ❌ Deep nesting: reading /orgs downloads ALL members and ALL their posts
// orgs/acme/members/alice123/posts/post1: { ... }
// ✅ Flat: each path is independently readable
// /users/alice123: { name, email }
// /userPosts/alice123/post1: { title, body }
// /orgMembers/acme/alice123: true
// --- Fan-out: atomic multi-path write ---
const post = { title: "Hello", body: "World", authorId: "alice123" }
const postId = "post1"
await update(ref(db), {
[`/posts/${postId}`]: post,
[`/userPosts/alice123/${postId}`]: true, // denormalized index
[`/feedItems/alice123/${postId}`]: { title: post.title },
})
// All 3 paths write atomically — partial writes never occur
// --- Delete a path (set to null) ---
await set(ref(db, "users/alice123/status"), null)
// --- Read nested value directly ---
const statusSnap = await get(ref(db, "users/alice123/status"))
console.log(statusSnap.val()) // "away" (or null if deleted)Cloud Functions JSON Request Handling
Bottom line: HTTP Cloud Functions (onRequest) auto-parse application/json bodies — req.body is already a JavaScript object. Callable functions (onCall) receive already-parsed data. Validate both with Zod and use HttpsError for structured error responses.
onRequest functions use Express.js under the hood. The Firebase Functions SDK adds a bodyParser middleware that parses Content-Type: application/json automatically, so req.body is a plain JavaScript object with no extra setup. onCall callable functions are simpler: the client SDK serializes the call argument to JSON and sends it, the server SDK deserializes it, and you receive the result as the data parameter. Both function types benefit from Zod validation at the entry point — this is your first line of defense against malformed requests. Use safeParse so you can return a 400 error instead of a 500 exception.HttpsError codes map to HTTP status codes: "invalid-argument" returns 400, "not-found" returns 404, "permission-denied" returns 403.
import * as functions from "firebase-functions/v2/https"
import { onRequest, onCall, HttpsError } from "firebase-functions/v2/https"
import { z } from "zod"
// --- Zod schemas for request/response ---
const CreateUserSchema = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
plan: z.enum(["free", "pro"]).default("free"),
})
const UpdateScoreSchema = z.object({
userId: z.string().min(1),
delta: z.number().int().min(-1000).max(1000),
})
// --- onRequest: HTTP function (Express-style) ---
// req.body is auto-parsed from application/json — no JSON.parse needed
export const createUser = onRequest(async (req, res) => {
if (req.method !== "POST") {
res.status(405).json({ error: "Method not allowed" })
return
}
// Validate with Zod safeParse (returns 400 instead of throwing 500)
const result = CreateUserSchema.safeParse(req.body)
if (!result.success) {
res.status(400).json({
error: "Validation failed",
details: result.error.flatten().fieldErrors,
})
return
}
const { name, email, plan } = result.data
// ... your business logic here ...
const userId = `user_${Date.now()}`
res.status(201).json({
success: true,
data: { userId, name, email, plan },
})
})
// --- onCall: callable function ---
// data is already parsed from the client SDK — no JSON.parse needed
export const updateScore = onCall(async (request) => {
// request.auth contains auth context (null if unauthenticated)
if (!request.auth) {
throw new HttpsError("unauthenticated", "Must be signed in")
}
const result = UpdateScoreSchema.safeParse(request.data)
if (!result.success) {
// HttpsError("invalid-argument") → HTTP 400 on the client
throw new HttpsError(
"invalid-argument",
"Invalid request data",
result.error.flatten().fieldErrors // extra detail in error.details
)
}
const { userId, delta } = result.data
// ... update score in Firestore ...
return { success: true, newScore: 150 + delta } // returned as JSON
})
// Client calls it with:
// const fn = httpsCallable(functions, "updateScore")
// const result = await fn({ userId: "abc", delta: 10 })
// result.data.newScore → 160JSON Security Rules for Firestore
Bottom line: Firestore Security Rules validate the request.resource.data map on every write. This is your server-side enforcement layer for document shape — Firestore itself does not enforce schemas. Rules check field types, required fields, allowed values, and field count.
Security Rules are evaluated server-side before any write is committed, so they apply to all clients regardless of platform or SDK version. A write that violates a rule is rejected with a PERMISSION_DENIED error. Use request.resource.data to inspect the incoming document, and resource.data to inspect the existing document (for updates). The .keys().hasAll(['field1', 'field2']) method checks required fields; .hasOnly() checks that no extra fields are present. Field type checks use is string, is number, is bool, is list, is map, and is timestamp. These rules run on every write operation; reading does not trigger them. A rules change deploys in under 60 seconds globally with firebase deploy --only firestore:rules.
// firestore.rules
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// --- Users collection: strict document shape enforcement ---
match /users/{userId} {
allow read: if request.auth != null && request.auth.uid == userId;
allow create: if request.auth != null
&& request.auth.uid == userId
// Required fields must all be present
&& request.resource.data.keys().hasAll(['name', 'email', 'plan', 'createdAt'])
// No extra fields beyond the allowed set (max 10 top-level fields)
&& request.resource.data.keys().hasOnly(['name', 'email', 'plan', 'createdAt', 'score', 'avatarUrl'])
&& request.resource.data.keys().size() <= 10
// Type checks
&& request.resource.data.name is string
&& request.resource.data.name.size() >= 1
&& request.resource.data.name.size() <= 100
&& request.resource.data.email is string
// Allowed values for plan field
&& request.resource.data.plan in ['free', 'pro', 'enterprise']
// score must be a non-negative number if present
&& (!('score' in request.resource.data)
|| (request.resource.data.score is number
&& request.resource.data.score >= 0))
// createdAt must be a timestamp
&& request.resource.data.createdAt is timestamp;
allow update: if request.auth != null
&& request.auth.uid == userId
// Immutable fields: email and createdAt cannot change after creation
&& request.resource.data.email == resource.data.email
&& request.resource.data.createdAt == resource.data.createdAt
// plan must still be a valid value after update
&& request.resource.data.plan in ['free', 'pro', 'enterprise']
// Users cannot self-upgrade to enterprise (admin-only)
&& (request.resource.data.plan != 'enterprise'
|| resource.data.plan == 'enterprise');
allow delete: if false; // no client-side deletes
}
// --- Posts collection: public read, authenticated write ---
match /posts/{postId} {
allow read: if true;
allow create: if request.auth != null
&& request.resource.data.keys().hasAll(['title', 'body', 'authorId'])
&& request.resource.data.title is string
&& request.resource.data.title.size() <= 200
&& request.resource.data.body is string
&& request.resource.data.body.size() <= 50000
// authorId must match the authenticated user
&& request.resource.data.authorId == request.auth.uid;
}
}
}FAQ
How do I read a Firestore document as a JavaScript object?
Call getDoc(docRef) to fetch a DocumentSnapshot, then call .data() on the snapshot. Always check snap.exists() first — if the document doesn't exist, .data() returns undefined. Timestamp fields come back as Timestamp objects; call .toDate().toISOString() before passing them to JSON.stringify. For typed reads, apply withConverter() to the reference so .data() returns your TypeScript interface.
How do I add TypeScript types to Firestore reads and writes?
Use withConverter() with a FirestoreDataConverter<T> that implements toFirestore and fromFirestore. Apply it to every collection or document reference where you want typed access. After applying, getDoc() returns DocumentSnapshot<T> and .data() returns T | undefined — fully typed, zero casting. The converter is not global; each reference needs it applied individually.
How do I validate Firestore data with Zod?
Define a Zod schema for the document shape and call it inside fromFirestore. Use safeParse for graceful degradation: if validation fails, log the error and return null so callers can filter invalid documents without crashing. Use z.infer<typeof Schema> to derive the TypeScript type from the Zod schema — this keeps the type and the runtime validation in sync as you evolve the schema.
What is the difference between Firestore and Realtime Database JSON storage?
Firestore organizes data as documents in collections, supports compound queries, and limits documents to 1 MB with 20,000 fields maximum. Realtime Database is a single JSON tree (max 32 levels of nesting) with path-based access and no compound queries — reading a parent node downloads all children. Choose Firestore for structured querying. Choose Realtime Database for low-latency sync of simple data like presence, chat messages, or live game state.
How do I handle Firebase Timestamps in JSON serialization?
Firestore Timestamp objects are not plain JSON — JSON.stringify serializes them as {"seconds":N,"nanoseconds":N}, not ISO strings. Call timestamp.toDate().toISOString() before stringifying. In fromFirestore, convert incoming Timestamp fields to JavaScript Date objects or ISO strings depending on what your application expects. When writing a JavaScript Date via toFirestore, Firestore converts it to a Timestamp automatically.
How do I validate JSON in a Cloud Function?
For onRequest functions, req.body is already a parsed JavaScript object for application/json requests — no manual JSON.parse needed. Validate it with Zod.safeParse and return a 400 response on failure. For onCall callable functions, the data parameter is already parsed. Throw new HttpsError("invalid-argument", "message") for structured error responses — the client SDK receives a typed error with code, message, and details fields.
Key Terms
DocumentSnapshot- The object returned by
getDoc(). Call.exists()to check if the document exists and.data()to get the document's fields as a JavaScript object. withConverter()- A method on Firestore collection and document references that attaches a
FirestoreDataConverter, giving typed reads and writes. Must be applied to each reference individually — it is not global. FirestoreDataConverter- A TypeScript interface requiring two methods:
toFirestore(data: T)for serializing before writes andfromFirestore(snapshot)for deserializing after reads. - Realtime Database tree
- The single JSON object that is the Realtime Database. All data lives under one root, addressed by path strings like
"users/alice123/status". Reading any node returns that node and all its children. - Security Rules
- Server-side rules evaluated before every Firestore or Realtime Database write (and read, if configured). They use
request.resource.datato inspect the incoming document and enforce document shape, field types, and allowed values. onRequest- The Cloud Functions trigger for HTTP requests. The Firebase SDK wraps the handler with Express.js and auto-parses
application/jsonbodies, soreq.bodyis a plain object with no manual parsing needed.
Working with other databases?
The same JSON validation patterns — Zod schemas, runtime type checking, TypeScript inference — apply to PostgreSQL and MySQL via ORMs. See the equivalent guides for JSON in Supabase and JSON in MongoDB.
Open JSON Formatter at jsonic.ioFurther reading and primary sources
- Firestore data model — Official Firestore documentation covering collections, documents, subcollections, document size limits (1 MB), and supported data types including Timestamp and GeoPoint.
- Firestore withConverter() reference — Firebase JS SDK reference for withConverter() — defining FirestoreDataConverter with toFirestore and fromFirestore for typed reads and writes.
- Realtime Database structure your data — Official guide to structuring data in the Realtime Database JSON tree, covering denormalization, fan-out, and the 32-level nesting limit.
- Cloud Functions HTTP triggers — Official documentation for onRequest and onCall Cloud Functions, including request body parsing, callable function data handling, and HttpsError usage.
- Firestore Security Rules reference — Official reference for Firestore Security Rules conditions: request.resource.data field checks, type validation, key set operations, and allowed value constraints.