The complete guide to integrating Firebase (Firestore & Auth) with Svelte 5's runes-based reactivity system. This document covers everything from basic subscriptions to advanced "Live Object" patterns that make your Firestore documents feel like local state.
What you'll learn:
- Subscribe to Firestore data with automatic cleanup
- Build reactive queries that re-subscribe when filters change
- Create "Live Objects" where
todo.completed = trueautomatically syncs to Firestore - Handle authentication state across your entire app
- Implement optimistic updates for instant UI feedback
- Navigate SSR considerations in SvelteKit
Dependencies: This guide uses only the native Firebase SDK—no rxfire or RxJS required.
Table of Contents
- Part 1: Foundations
- Part 2: Read Patterns
- Part 3: Authentication
- Part 4: The Live Object Pattern
- Part 5: Advanced Patterns
- Part 6: SSR & SvelteKit Integration
- Part 7: Reference
Part 1: Foundations
1.1 The Challenge: From $: to Runes
In Svelte 4, integrating Firebase observables was elegant:
<!-- Svelte 4 -->
<script>
import { collectionStore } from 'sveltefire';
// $: automatically re-ran when dependencies changed
// $ prefix auto-subscribed to the store
$: todos = $collectionStore(collection(db, 'todos'));
</script>
{#each todos as todo}
<p>{todo.title}</p>
{/each}
Svelte 5 replaces $: with runes ($state, $derived, $effect). The store $ prefix still works, but the patterns for external subscriptions have changed. This guide shows you how to build clean, type-safe Firebase integrations using the new paradigm.
1.2 Project Setup
Installation
npm install firebase
SSR-Safe Firebase Initialization
Firebase only works in the browser. We must guard initialization for SvelteKit's server-side rendering.
// lib/firebase/client.ts
import { browser } from '$app/environment';
import { initializeApp, getApps, type FirebaseApp } from 'firebase/app';
import { getFirestore, type Firestore } from 'firebase/firestore';
import { getAuth, type Auth } from 'firebase/auth';
const firebaseConfig = {
apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET,
messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID,
appId: import.meta.env.VITE_FIREBASE_APP_ID
};
let app: FirebaseApp | undefined;
let db: Firestore | undefined;
let auth: Auth | undefined;
if (browser) {
app = getApps().length === 0 ? initializeApp(firebaseConfig) : getApps()[0];
db = getFirestore(app);
auth = getAuth(app);
}
export { app, db, auth };
Environment Variables
Create .env (and add to .gitignore):
VITE_FIREBASE_API_KEY=your-api-key
VITE_FIREBASE_AUTH_DOMAIN=your-project.firebaseapp.com
VITE_FIREBASE_PROJECT_ID=your-project
VITE_FIREBASE_STORAGE_BUCKET=your-project.appspot.com
VITE_FIREBASE_MESSAGING_SENDER_ID=123456789
VITE_FIREBASE_APP_ID=1:123456789:web:abc123
1.3 Core Types & Interfaces
// lib/firebase/types.ts
/** Document with its Firestore ID included */
export type WithId<T> = T & { id: string };
/** Standard state for any Firestore subscription */
export interface FirestoreState<T> {
readonly data: T;
readonly loading: boolean;
readonly error: Error | null;
}
/** Extended state for collections */
export interface CollectionState<T> extends FirestoreState<T[]> {
readonly empty: boolean;
readonly count: number;
}
/** Extended state for single documents */
export interface DocumentState<T> extends FirestoreState<T | null> {
readonly exists: boolean;
}
Recommended File Structure
src/lib/firebase/
├── client.ts # Firebase initialization
├── types.ts # Shared TypeScript types
├── firestore.svelte.ts # useCollection, useDocument
├── auth.svelte.ts # Auth state management
├── live-model.svelte.ts # LiveModel class
├── live-collection.svelte.ts # LiveCollection class
└── sync-manager.svelte.ts # Global sync state
Part 2: Read Patterns
All read utilities use Firestore's native onSnapshot for real-time updates and leverage Svelte 5's $effect for automatic subscription lifecycle management.
2.1 useDocument: Single Document Subscriptions
// lib/firebase/firestore.svelte.ts
import { browser } from '$app/environment';
import { doc, onSnapshot, type DocumentData } from 'firebase/firestore';
import { db } from './client';
import type { DocumentState, WithId } from './types';
/**
* Subscribe to a single Firestore document.
*
* @param getPath - Function returning the document path, or null to skip subscription
* @returns Reactive state object with data, loading, error, and exists properties
*/
export function useDocument<T extends DocumentData>(
getPath: () => string | null | undefined
): DocumentState<WithId<T>> {
let data = $state<WithId<T> | null>(null);
let loading = $state(true);
let error = $state<Error | null>(null);
$effect(() => {
// Skip on server
if (!browser || !db) {
loading = true;
return;
}
const path = getPath();
// Handle null path (no subscription)
if (!path) {
data = null;
loading = false;
error = null;
return;
}
loading = true;
error = null;
const docRef = doc(db, path);
const unsubscribe = onSnapshot(
docRef,
(snapshot) => {
if (snapshot.exists()) {
data = { id: snapshot.id, ...snapshot.data() } as WithId<T>;
} else {
data = null;
}
loading = false;
},
(err) => {
error = err;
loading = false;
}
);
return () => unsubscribe();
});
return {
get data() { return data; },
get loading() { return loading; },
get error() { return error; },
get exists() { return data !== null; }
};
}
Example usage:
<script lang="ts">
import { useDocument } from '$lib/firebase/firestore.svelte';
interface Todo {
title: string;
completed: boolean;
}
let { id } = $props();
const todo = useDocument<Todo>(() => `todos/${id}`);
</script>
{#if todo.loading}
<p>Loading...</p>
{:else if !todo.exists}
<p>Not found</p>
{:else}
<h1>{todo.data?.title}</h1>
{/if}
2.2 useCollection: Collection Subscriptions
// lib/firebase/firestore.svelte.ts (continued)
import {
collection,
query,
onSnapshot,
type CollectionReference,
type Query,
type QueryConstraint,
type DocumentData
} from 'firebase/firestore';
/**
* Subscribe to a Firestore collection or query.
*
* @param getRef - Function returning a collection/query reference, or null to skip
* @returns Reactive state object with data array, loading, error, empty, and count
*/
export function useCollection<T extends DocumentData>(
getRef: () => CollectionReference<T> | Query<T> | null | undefined
): CollectionState<WithId<T>> {
let data = $state<WithId<T>[]>([]);
let loading = $state(true);
let error = $state<Error | null>(null);
$effect(() => {
if (!browser || !db) {
loading = true;
return;
}
const ref = getRef();
if (!ref) {
data = [];
loading = false;
error = null;
return;
}
loading = true;
error = null;
const unsubscribe = onSnapshot(
ref,
(snapshot) => {
data = snapshot.docs.map(doc => ({
id: doc.id,
...doc.data()
})) as WithId<T>[];
loading = false;
},
(err) => {
error = err;
loading = false;
}
);
return () => unsubscribe();
});
return {
get data() { return data; },
get loading() { return loading; },
get error() { return error; },
get empty() { return data.length === 0; },
get count() { return data.length; }
};
}
Example usage:
<script lang="ts">
import { useCollection } from '$lib/firebase/firestore.svelte';
import { collection, query, orderBy } from 'firebase/firestore';
import { db } from '$lib/firebase/client';
interface Todo {
title: string;
completed: boolean;
}
const todos = useCollection<Todo>(() =>
db ? query(collection(db, 'todos'), orderBy('createdAt', 'desc')) : null
);
</script>
{#each todos.data as todo (todo.id)}
<TodoItem {todo} />
{/each}
2.3 Reactive Queries: Dynamic Re-subscription
The power of $effect is that it tracks dependencies. When you pass a getter function that references reactive state, the subscription automatically re-creates when that state changes.
<script lang="ts">
import { useCollection } from '$lib/firebase/firestore.svelte';
import { collection, query, where, orderBy, limit } from 'firebase/firestore';
import { db } from '$lib/firebase/client';
interface Product {
name: string;
category: string;
price: number;
inStock: boolean;
}
// Reactive filter state
let selectedCategory = $state<string | null>(null);
let showInStockOnly = $state(false);
let sortBy = $state<'price' | 'name'>('name');
let pageSize = $state(10);
// Query automatically rebuilds when ANY dependency changes
const products = useCollection<Product>(() => {
if (!db) return null;
const constraints: QueryConstraint[] = [];
if (selectedCategory) {
constraints.push(where('category', '==', selectedCategory));
}
if (showInStockOnly) {
constraints.push(where('inStock', '==', true));
}
constraints.push(orderBy(sortBy));
constraints.push(limit(pageSize));
return query(collection(db, 'products'), ...constraints);
});
</script>
<div class="filters">
<select bind:value={selectedCategory}>
<option value={null}>All Categories</option>
<option value="electronics">Electronics</option>
<option value="clothing">Clothing</option>
</select>
<label>
<input type="checkbox" bind:checked={showInStockOnly} />
In Stock Only
</label>
<select bind:value={sortBy}>
<option value="name">Sort by Name</option>
<option value="price">Sort by Price</option>
</select>
</div>
{#if products.loading}
<p>Loading...</p>
{:else}
<div class="product-grid">
{#each products.data as product (product.id)}
<ProductCard {product} />
{/each}
</div>
{/if}
Debouncing Rapid Changes
For search inputs, debounce to prevent excessive re-subscriptions:
<script lang="ts">
import { useCollection } from '$lib/firebase/firestore.svelte';
import { collection, query, where, orderBy } from 'firebase/firestore';
import { db } from '$lib/firebase/client';
let searchInput = $state('');
let debouncedSearch = $state('');
// Debounce effect
$effect(() => {
const timer = setTimeout(() => {
debouncedSearch = searchInput;
}, 300);
return () => clearTimeout(timer);
});
// Query uses debounced value
const results = useCollection<Product>(() => {
if (!db || !debouncedSearch.trim()) return null;
return query(
collection(db, 'products'),
where('name', '>=', debouncedSearch),
where('name', '<=', debouncedSearch + '\uf8ff'),
orderBy('name')
);
});
</script>
<input type="search" bind:value={searchInput} placeholder="Search..." />
{#if results.data.length > 0}
{#each results.data as product}
<p>{product.name}</p>
{/each}
{:else if !results.loading && debouncedSearch}
<p>No results found</p>
{/if}
Part 3: Authentication
3.1 Auth State Singleton
Authentication is a global concern. We use a singleton class pattern that can be initialized once in your root layout.
// lib/firebase/auth.svelte.ts
import { browser } from '$app/environment';
import {
onAuthStateChanged,
signInWithEmailAndPassword,
signInWithPopup,
signOut as firebaseSignOut,
GoogleAuthProvider,
createUserWithEmailAndPassword,
type User,
type UserCredential
} from 'firebase/auth';
import { auth } from './client';
class AuthState {
#user = $state<User | null>(null);
#loading = $state(true);
#initialized = false;
/**
* Initialize auth listener. Call once in root layout.
*/
init() {
if (this.#initialized || !browser || !auth) return;
this.#initialized = true;
onAuthStateChanged(auth, (user) => {
this.#user = user;
this.#loading = false;
});
}
// Getters
get user() { return this.#user; }
get loading() { return this.#loading; }
get isAuthenticated() { return this.#user !== null; }
get uid() { return this.#user?.uid ?? null; }
get email() { return this.#user?.email ?? null; }
get displayName() { return this.#user?.displayName ?? null; }
// Actions
async signIn(email: string, password: string): Promise<UserCredential> {
if (!auth) throw new Error('Auth not initialized');
return signInWithEmailAndPassword(auth, email, password);
}
async signUp(email: string, password: string): Promise<UserCredential> {
if (!auth) throw new Error('Auth not initialized');
return createUserWithEmailAndPassword(auth, email, password);
}
async signInWithGoogle(): Promise<UserCredential> {
if (!auth) throw new Error('Auth not initialized');
const provider = new GoogleAuthProvider();
return signInWithPopup(auth, provider);
}
async signOut(): Promise<void> {
if (!auth) throw new Error('Auth not initialized');
return firebaseSignOut(auth);
}
}
// Singleton export
export const authState = new AuthState();
Initialize in Root Layout
<!-- routes/+layout.svelte -->
<script lang="ts">
import { authState } from '$lib/firebase/auth.svelte';
import { onMount } from 'svelte';
let { children } = $props();
onMount(() => {
authState.init();
});
</script>
{#if authState.loading}
<div class="auth-loading">
<p>Loading...</p>
</div>
{:else}
{@render children()}
{/if}
3.2 User-Scoped Data
Combine auth state with Firestore queries to automatically filter by current user:
<script lang="ts">
import { useCollection } from '$lib/firebase/firestore.svelte';
import { authState } from '$lib/firebase/auth.svelte';
import { collection, query, where, orderBy } from 'firebase/firestore';
import { db } from '$lib/firebase/client';
interface Todo {
title: string;
completed: boolean;
userId: string;
}
// Query automatically updates when auth state changes
const myTodos = useCollection<Todo>(() => {
const uid = authState.uid;
if (!db || !uid) return null;
return query(
collection(db, 'todos'),
where('userId', '==', uid),
orderBy('createdAt', 'desc')
);
});
</script>
{#if !authState.isAuthenticated}
<p>Please sign in to view your todos.</p>
{:else if myTodos.loading}
<p>Loading your todos...</p>
{:else if myTodos.empty}
<p>You have no todos yet.</p>
{:else}
{#each myTodos.data as todo (todo.id)}
<div>{todo.title}</div>
{/each}
{/if}
3.3 Protected Routes
Use $effect to redirect unauthenticated users:
<!-- routes/dashboard/+page.svelte -->
<script lang="ts">
import { authState } from '$lib/firebase/auth.svelte';
import { goto } from '$app/navigation';
// Redirect if not authenticated
$effect(() => {
if (!authState.loading && !authState.isAuthenticated) {
goto('/login');
}
});
</script>
{#if authState.loading}
<p>Checking authentication...</p>
{:else if authState.isAuthenticated}
<h1>Welcome, {authState.displayName ?? authState.email}!</h1>
<!-- Dashboard content -->
{/if}
Part 4: The Live Object Pattern
This is where Svelte 5 truly shines. Instead of manually calling updateDoc every time you want to change data, we create Live Objects—JavaScript objects that automatically persist changes to Firestore.
<!-- The dream API -->
<input type="checkbox" bind:checked={todo.completed} />
<input bind:value={todo.title} />
<select bind:value={todo.meta.priority}>...</select>
Every change automatically syncs to Firestore using surgical dot-notation updates.
4.1 The Deep Path Proxy
The core of this pattern is a recursive JavaScript Proxy that:
- Tracks the path as you descend into nested objects (
meta.author.name) - Intercepts all property assignments
- Triggers Firestore updates using dot-notation
4.2 LiveModel: Auto-Syncing Documents
// lib/firebase/live-model.svelte.ts
import {
updateDoc,
deleteDoc,
arrayUnion,
arrayRemove,
type DocumentReference
} from 'firebase/firestore';
export interface LiveModelOptions {
/** Debounce delay in ms for text fields (default: 400) */
debounceMs?: number;
/** Fields that should be debounced (default: all string fields) */
debounceFields?: string[];
}
export class LiveModel<T extends object> {
readonly id: string;
#data: T;
#ref: DocumentReference;
#isUpdatingFromServer = false;
#syncing = $state(false);
#debounceTimers = new Map<string, ReturnType<typeof setTimeout>>();
#options: Required<LiveModelOptions>;
/** The reactive, auto-syncing data object */
model: T;
constructor(
ref: DocumentReference,
initialData: T & { id: string },
options: LiveModelOptions = {}
) {
this.id = initialData.id;
this.#ref = ref;
this.#data = $state(initialData) as T;
this.#options = {
debounceMs: options.debounceMs ?? 400,
debounceFields: options.debounceFields ?? []
};
// Create the recursive proxy
this.model = this.#createProxy(this.#data, []);
}
#createProxy(target: any, path: string[]): any {
const self = this;
return new Proxy(target, {
get(obj, prop) {
// Skip symbols and internal properties
if (typeof prop === 'symbol' || prop === 'then') {
return obj[prop];
}
const value = obj[prop];
// Handle array methods specially
if (Array.isArray(obj)) {
if (prop === 'push') {
return (...args: any[]) => {
const result = Array.prototype.push.apply(obj, args);
if (!self.#isUpdatingFromServer) {
const dotPath = path.join('.');
self.#persistArrayUnion(dotPath, args);
}
return result;
};
}
// Add more array methods as needed (pop, splice, etc.)
}
// Recursively wrap nested objects
if (value && typeof value === 'object' && !(value instanceof Date) && !(value instanceof Blob)) {
return self.#createProxy(value, [...path, String(prop)]);
}
return value;
},
set(obj, prop, value) {
if (typeof prop === 'symbol') {
obj[prop] = value;
return true;
}
// Update local state immediately
obj[prop] = value;
// Sync to Firestore if not from server
if (!self.#isUpdatingFromServer) {
const dotPath = [...path, String(prop)].join('.');
// Debounce string fields
if (typeof value === 'string' && self.#shouldDebounce(dotPath)) {
self.#debouncedPersist(dotPath, value);
} else {
self.#persist(dotPath, value);
}
}
return true;
}
});
}
#shouldDebounce(path: string): boolean {
const { debounceFields } = this.#options;
if (debounceFields.length === 0) return true; // Debounce all strings by default
return debounceFields.some(field => path === field || path.startsWith(field + '.'));
}
#debouncedPersist(dotPath: string, value: any) {
// Clear existing timer
const existing = this.#debounceTimers.get(dotPath);
if (existing) clearTimeout(existing);
// Set new timer
const timer = setTimeout(() => {
this.#persist(dotPath, value);
this.#debounceTimers.delete(dotPath);
}, this.#options.debounceMs);
this.#debounceTimers.set(dotPath, timer);
}
async #persist(dotPath: string, value: any) {
this.#syncing = true;
try {
await updateDoc(this.#ref, { [dotPath]: value });
} catch (err) {
console.error(`Failed to sync ${dotPath}:`, err);
// Optional: revert local state, emit error event, etc.
} finally {
this.#syncing = false;
}
}
async #persistArrayUnion(dotPath: string, items: any[]) {
this.#syncing = true;
try {
await updateDoc(this.#ref, { [dotPath]: arrayUnion(...items) });
} catch (err) {
console.error(`Failed to sync array ${dotPath}:`, err);
} finally {
this.#syncing = false;
}
}
/**
* Update from server data without triggering write-back
*/
_updateFromServer(newData: T) {
this.#isUpdatingFromServer = true;
// Deep merge to preserve reactivity
this.#deepMerge(this.#data, newData);
this.#isUpdatingFromServer = false;
}
#deepMerge(target: any, source: any) {
for (const key of Object.keys(source)) {
if (
source[key] &&
typeof source[key] === 'object' &&
!Array.isArray(source[key]) &&
!(source[key] instanceof Date)
) {
if (!target[key]) target[key] = {};
this.#deepMerge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
}
/** Is there a pending sync operation? */
get isSyncing() { return this.#syncing; }
/** Delete this document from Firestore */
async delete() {
await deleteDoc(this.#ref);
}
/** Flush any pending debounced updates immediately */
flush() {
for (const [path, timer] of this.#debounceTimers) {
clearTimeout(timer);
// Get the current value at this path and persist it
const value = path.split('.').reduce((obj, key) => obj?.[key], this.#data as any);
if (value !== undefined) {
this.#persist(path, value);
}
}
this.#debounceTimers.clear();
}
}
4.3 LiveCollection: Reactive + Writable Collections
A key challenge with collections is maintaining query order. SvelteMap preserves insertion order, but Firestore queries define their own order via orderBy. When documents are added or modified, the Map's insertion order may not match the query order.
Solution: We use a hybrid approach:
-
SvelteMapfor O(1) lookups and efficient partial updates - A separate
#orderedIdsarray that reflects the query's ordering
// lib/firebase/live-collection.svelte.ts
import { browser } from '$app/environment';
import {
onSnapshot,
addDoc,
collection as firestoreCollection,
serverTimestamp,
type Query,
type CollectionReference,
type DocumentData
} from 'firebase/firestore';
import { SvelteMap } from 'svelte/reactivity';
import { LiveModel, type LiveModelOptions } from './live-model.svelte';
import { db } from './client';
/**
* A reactive collection where each document is a LiveModel.
* Documents auto-sync changes back to Firestore.
*
* Order is preserved according to the query's orderBy clause,
* not insertion order.
*/
export class LiveCollection<T extends DocumentData> {
/** Map for O(1) lookups by ID */
#itemsMap = new SvelteMap<string, LiveModel<T>>();
/** Ordered array of IDs (matches query orderBy) */
#orderedIds = $state<string[]>([]);
loading = $state(true);
error = $state<Error | null>(null);
#collectionPath: string;
#modelOptions: LiveModelOptions;
constructor(
queryFactory: () => Query | CollectionReference | null,
collectionPath: string,
modelOptions: LiveModelOptions = {}
) {
this.#collectionPath = collectionPath;
this.#modelOptions = modelOptions;
$effect(() => {
if (!browser || !db) {
this.loading = true;
return;
}
const q = queryFactory();
if (!q) {
this.#itemsMap.clear();
this.#orderedIds = [];
this.loading = false;
return;
}
this.loading = true;
const unsubscribe = onSnapshot(
q,
(snapshot) => {
// Process changes efficiently
snapshot.docChanges().forEach((change) => {
const id = change.doc.id;
const data = { id, ...change.doc.data() } as T & { id: string };
switch (change.type) {
case 'added':
this.#itemsMap.set(
id,
new LiveModel(change.doc.ref, data, this.#modelOptions)
);
break;
case 'modified':
// Update existing instance (preserves object identity)
this.#itemsMap.get(id)?._updateFromServer(data);
break;
case 'removed':
this.#itemsMap.delete(id);
break;
}
});
// KEY: Rebuild order from the FULL snapshot to match query orderBy
this.#orderedIds = snapshot.docs.map(doc => doc.id);
this.loading = false;
this.error = null;
},
(err) => {
this.error = err;
this.loading = false;
}
);
return () => unsubscribe();
});
}
/** Get documents in query order (respects orderBy) */
get list(): LiveModel<T>[] {
return this.#orderedIds
.map(id => this.#itemsMap.get(id))
.filter((item): item is LiveModel<T> => item !== undefined);
}
/** Direct access to the map (for O(1) lookups) */
get items(): SvelteMap<string, LiveModel<T>> {
return this.#itemsMap;
}
/** Number of documents */
get count(): number {
return this.#orderedIds.length;
}
/** Is the collection empty? */
get empty(): boolean {
return this.#orderedIds.length === 0;
}
/** Add a new document to the collection */
async add(data: Omit<T, 'id'>): Promise<string> {
if (!db) throw new Error('Firestore not initialized');
const ref = firestoreCollection(db, this.#collectionPath);
const docRef = await addDoc(ref, {
...data,
createdAt: serverTimestamp(),
updatedAt: serverTimestamp()
});
return docRef.id;
}
/** Get a specific document by ID (O(1) lookup) */
get(id: string): LiveModel<T> | undefined {
return this.#itemsMap.get(id);
}
/** Check if a document exists */
has(id: string): boolean {
return this.#itemsMap.has(id);
}
}
Why This Approach?
| Component | Purpose |
|---|---|
#itemsMap |
O(1) lookups, efficient updates via docChanges()
|
#orderedIds |
Maintains query order from snapshot.docs
|
list getter |
Returns items in correct order by mapping over #orderedIds
|
The key line is:
this.#orderedIds = snapshot.docs.map(doc => doc.id);
This ensures that no matter what changes occurred (adds, modifies, deletes, reorders), the list getter always returns documents in the order specified by your query's orderBy clause.
4.4 Usage: The Magic in Action
<script lang="ts">
import { LiveCollection } from '$lib/firebase/live-collection.svelte';
import { collection, query, where, orderBy } from 'firebase/firestore';
import { db } from '$lib/firebase/client';
import { authState } from '$lib/firebase/auth.svelte';
interface Todo {
id: string;
title: string;
completed: boolean;
meta: {
tags: string[];
priority: 'low' | 'medium' | 'high';
notes: string;
};
userId: string;
}
// Create the live collection
const todos = new LiveCollection<Todo>(
() => {
if (!db || !authState.uid) return null;
return query(
collection(db, 'todos'),
where('userId', '==', authState.uid),
orderBy('createdAt', 'desc')
);
},
'todos',
{ debounceMs: 500 }
);
let newTitle = $state('');
async function addTodo() {
if (!newTitle.trim() || !authState.uid) return;
await todos.add({
title: newTitle,
completed: false,
meta: {
tags: [],
priority: 'medium',
notes: ''
},
userId: authState.uid
});
newTitle = '';
}
</script>
<form onsubmit={addTodo}>
<input bind:value={newTitle} placeholder="New todo..." />
<button type="submit">Add</button>
</form>
{#if todos.loading}
<p>Loading...</p>
{:else if todos.empty}
<p>No todos yet!</p>
{:else}
<ul>
{#each todos.list as wrapper (wrapper.id)}
{@const todo = wrapper.model}
<li class:syncing={wrapper.isSyncing}>
<!-- Shallow update: { completed: true } -->
<input type="checkbox" bind:checked={todo.completed} />
<!-- Shallow update: { title: "..." } -->
<input bind:value={todo.title} />
<!-- Deep update: { "meta.priority": "high" } -->
<select bind:value={todo.meta.priority}>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
<!-- Deep update: { "meta.notes": "..." } -->
<textarea bind:value={todo.meta.notes} placeholder="Notes..." />
<button onclick={() => wrapper.delete()}>Delete</button>
{#if wrapper.isSyncing}
<span class="sync-badge">Saving...</span>
{/if}
</li>
{/each}
</ul>
{/if}
<style>
.syncing {
opacity: 0.8;
}
.sync-badge {
font-size: 0.75rem;
color: #666;
}
</style>
4.5 Handling Arrays
Firestore arrays need special operations (arrayUnion, arrayRemove) for safe concurrent updates.
<script lang="ts">
// Assuming todo.model.meta.tags is an array
function addTag(tag: string) {
// Uses arrayUnion under the hood
todo.model.meta.tags.push(tag);
}
// For removal, you'd need a custom method:
async function removeTag(tag: string) {
const index = todo.model.meta.tags.indexOf(tag);
if (index > -1) {
todo.model.meta.tags.splice(index, 1);
// Manual sync needed for removal
await updateDoc(doc(db, 'todos', todo.id), {
'meta.tags': arrayRemove(tag)
});
}
}
</script>
<div class="tags">
{#each todo.model.meta.tags as tag}
<span class="tag">
{tag}
<button onclick={() => removeTag(tag)}>×</button>
</span>
{/each}
<button onclick={() => addTag('new-tag')}>+ Add Tag</button>
</div>
Part 5: Advanced Patterns
5.1 Optimistic Updates
For read-only subscriptions where you want instant feedback:
// lib/firebase/optimistic.svelte.ts
import type { CollectionState, WithId } from './types';
/**
* Wrap a collection state with optimistic update capabilities
*/
export function withOptimistic<T extends WithId<any>>(
collection: CollectionState<T>
) {
// Optimistic overrides: id -> partial update (null = deleted)
let overrides = $state<Map<string, Partial<T> | null>>(new Map());
// Pending additions
let pendingAdds = $state<T[]>([]);
const merged = $derived.by(() => {
// Start with pending additions
let result: T[] = [...pendingAdds];
// Add and transform server data
for (const item of collection.data) {
const override = overrides.get(item.id);
if (override === null) continue; // Optimistically deleted
if (override) {
result.push({ ...item, ...override });
} else {
result.push(item);
}
}
return result;
});
return {
get data() { return merged; },
get loading() { return collection.loading; },
get error() { return collection.error; },
get empty() { return merged.length === 0; },
get count() { return merged.length; },
get hasPendingChanges() {
return overrides.size > 0 || pendingAdds.length > 0;
},
optimisticAdd(item: T) {
pendingAdds = [...pendingAdds, item];
},
optimisticUpdate(id: string, updates: Partial<T>) {
overrides.set(id, { ...overrides.get(id), ...updates });
},
optimisticDelete(id: string) {
overrides.set(id, null);
},
clearOptimistic(id: string) {
overrides.delete(id);
pendingAdds = pendingAdds.filter(item => item.id !== id);
},
clearAll() {
overrides.clear();
pendingAdds = [];
}
};
}
5.2 Global Sync Manager
Track all pending operations across your app:
// lib/firebase/sync-manager.svelte.ts
class SyncManager {
#pendingCount = $state(0);
#errors = $state<Error[]>([]);
get isSaving() { return this.#pendingCount > 0; }
get pendingCount() { return this.#pendingCount; }
get errors() { return this.#errors; }
get hasErrors() { return this.#errors.length > 0; }
async track<T>(promise: Promise<T>): Promise<T> {
this.#pendingCount++;
try {
const result = await promise;
return result;
} catch (err) {
this.#errors = [...this.#errors, err as Error];
throw err;
} finally {
this.#pendingCount--;
}
}
clearErrors() {
this.#errors = [];
}
}
export const syncManager = new SyncManager();
Usage in your layout:
<!-- routes/+layout.svelte -->
<script>
import { syncManager } from '$lib/firebase/sync-manager.svelte';
</script>
{#if syncManager.isSaving}
<div class="global-sync-indicator">
Saving... ({syncManager.pendingCount})
</div>
{/if}
{#if syncManager.hasErrors}
<div class="error-toast">
Some changes failed to save.
<button onclick={() => syncManager.clearErrors()}>Dismiss</button>
</div>
{/if}
5.3 Error Handling & Retry
// lib/firebase/utils.ts
export async function withRetry<T>(
fn: () => Promise<T>,
options: { maxAttempts?: number; delayMs?: number } = {}
): Promise<T> {
const { maxAttempts = 3, delayMs = 1000 } = options;
let lastError: Error | undefined;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (err) {
lastError = err as Error;
if (attempt < maxAttempts) {
await new Promise(r => setTimeout(r, delayMs * attempt));
}
}
}
throw lastError;
}
Part 6: SSR & SvelteKit Integration
6.1 Server-Side Data Loading with Admin SDK
For SEO-critical pages, fetch data server-side:
// lib/firebase/admin.server.ts
import { initializeApp, getApps, cert, type App } from 'firebase-admin/app';
import { getFirestore, type Firestore } from 'firebase-admin/firestore';
import { FIREBASE_SERVICE_ACCOUNT } from '$env/static/private';
let app: App;
let adminDb: Firestore;
if (getApps().length === 0) {
const serviceAccount = JSON.parse(FIREBASE_SERVICE_ACCOUNT);
app = initializeApp({
credential: cert(serviceAccount)
});
}
adminDb = getFirestore();
export { adminDb };
// routes/products/+page.server.ts
import { adminDb } from '$lib/firebase/admin.server';
export async function load() {
const snapshot = await adminDb
.collection('products')
.orderBy('createdAt', 'desc')
.limit(20)
.get();
const products = snapshot.docs.map(doc => ({
id: doc.id,
...doc.data()
}));
return { products };
}
6.2 Hydration Strategy
Start with server data, then switch to real-time:
<!-- routes/products/+page.svelte -->
<script lang="ts">
import { useCollection } from '$lib/firebase/firestore.svelte';
import { collection, orderBy, limit, query } from 'firebase/firestore';
import { db } from '$lib/firebase/client';
import { browser } from '$app/environment';
interface Product {
name: string;
price: number;
}
// Server-provided data
let { data } = $props();
// Client-side real-time subscription
const liveProducts = useCollection<Product>(() => {
if (!db) return null;
return query(collection(db, 'products'), orderBy('createdAt', 'desc'), limit(20));
});
// Use server data until client takes over
let products = $derived(
liveProducts.loading ? data.products : liveProducts.data
);
</script>
{#each products as product (product.id)}
<ProductCard {product} />
{/each}
6.3 Session-Based Auth in Hooks
For server-side auth checks:
// hooks.server.ts
import { redirect, type Handle } from '@sveltejs/kit';
import { adminDb } from '$lib/firebase/admin.server';
import { getAuth } from 'firebase-admin/auth';
export const handle: Handle = async ({ event, resolve }) => {
const sessionCookie = event.cookies.get('session');
if (event.url.pathname.startsWith('/dashboard')) {
if (!sessionCookie) {
throw redirect(303, '/login');
}
try {
const decodedClaims = await getAuth().verifySessionCookie(sessionCookie, true);
event.locals.user = decodedClaims;
} catch {
event.cookies.delete('session', { path: '/' });
throw redirect(303, '/login');
}
}
return resolve(event);
};
Part 7: Reference
7.1 Complete API Reference
useDocument
function useDocument<T>(
getPath: () => string | null | undefined
): DocumentState<WithId<T>>
| Property | Type | Description |
|---|---|---|
data |
`WithId \ | null` |
loading |
boolean |
True during initial load |
error |
`Error \ | null` |
exists |
boolean |
True if document exists |
useCollection
function useCollection<T>(
getRef: () => CollectionReference<T> | Query<T> | null | undefined
): CollectionState<WithId<T>>
| Property | Type | Description |
|---|---|---|
data |
WithId<T>[] |
Array of documents |
loading |
boolean |
True during initial load |
error |
`Error \ | null` |
empty |
boolean |
True if no documents |
count |
number |
Number of documents |
LiveModel
class LiveModel<T extends object> {
readonly id: string;
readonly model: T; // Auto-syncing proxy
readonly isSyncing: boolean;
delete(): Promise<void>;
flush(): void; // Force pending debounced updates
_updateFromServer(data: T): void;
}
LiveCollection
class LiveCollection<T extends DocumentData> {
readonly items: SvelteMap<string, LiveModel<T>>; // For O(1) lookups
readonly list: LiveModel<T>[]; // Query-ordered array for {#each}
readonly loading: boolean;
readonly error: Error | null;
readonly count: number;
readonly empty: boolean;
add(data: Omit<T, 'id'>): Promise<string>;
get(id: string): LiveModel<T> | undefined; // O(1) lookup
has(id: string): boolean;
}
Note: The
listgetter returns documents in the order specified by your query'sorderByclause, not insertion order. Useitemsfor direct Map access when you need O(1) lookups by ID.
authState
const authState: {
readonly user: User | null;
readonly loading: boolean;
readonly isAuthenticated: boolean;
readonly uid: string | null;
readonly email: string | null;
readonly displayName: string | null;
init(): void;
signIn(email: string, password: string): Promise<UserCredential>;
signUp(email: string, password: string): Promise<UserCredential>;
signInWithGoogle(): Promise<UserCredential>;
signOut(): Promise<void>;
}
7.2 Migration from Svelte 4
Before (Svelte 4 + rxfire)
<script>
import { collectionData } from 'rxfire/firestore';
import { collection } from 'firebase/firestore';
$: todosStore = collectionData(collection(db, 'todos'));
</script>
{#each $todosStore ?? [] as todo}
<p>{todo.title}</p>
{/each}
After (Svelte 5 + this guide)
<script lang="ts">
import { useCollection } from '$lib/firebase/firestore.svelte';
import { collection } from 'firebase/firestore';
import { db } from '$lib/firebase/client';
interface Todo {
title: string;
completed: boolean;
}
const todos = useCollection<Todo>(() =>
db ? collection(db, 'todos') : null
);
</script>
{#if todos.loading}
<p>Loading...</p>
{:else}
{#each todos.data as todo (todo.id)}
<p>{todo.title}</p>
{/each}
{/if}
Key Differences
| Svelte 4 | Svelte 5 |
|---|---|
$: reactive statement |
$effect() rune |
$store auto-subscribe |
Explicit .data property |
| Implicit loading | Explicit .loading state |
| No error handling | Built-in .error state |
| rxfire dependency | Native onSnapshot
|
Summary
This guide covered the complete spectrum of Firebase integration with Svelte 5:
-
Read Patterns:
useDocumentanduseCollectionwith automatic subscription lifecycle - Reactive Queries: Dynamic re-subscription when filter state changes
-
Authentication: Singleton
authStatewith init-once pattern - Live Objects: Auto-syncing documents via Deep Path Proxy
- Advanced: Optimistic updates, global sync tracking, error handling
- SSR: Server-side data loading with Admin SDK, hydration strategies
The Live Object Pattern is the crown jewel—it transforms Firestore from a remote database into what feels like local state. Property assignments become database operations, and the developer experience approaches the simplicity of working with plain JavaScript objects.
Choose your level of abstraction:
-
Simple reads: Use
useCollection/useDocument -
Complex writes: Use
LiveCollectionwithLiveModel -
Full control: Use native Firestore APIs with
$effectfor cleanup
Happy building! 🔥
Top comments (0)