DEV Community

Evgenij (Eugene) Beloded
Evgenij (Eugene) Beloded

Posted on

Firebase + Svelte 5: The Definitive Guide

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 = true automatically 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

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}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 };
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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; }
  };
}
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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; }
  };
}
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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:

  1. Tracks the path as you descend into nested objects (meta.author.name)
  2. Intercepts all property assignments
  3. 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();
  }
}
Enter fullscreen mode Exit fullscreen mode

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:

  • SvelteMap for O(1) lookups and efficient partial updates
  • A separate #orderedIds array 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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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 = [];
    }
  };
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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 };
Enter fullscreen mode Exit fullscreen mode
// 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 };
}
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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);
};
Enter fullscreen mode Exit fullscreen mode

Part 7: Reference

7.1 Complete API Reference

useDocument

function useDocument<T>(
  getPath: () => string | null | undefined
): DocumentState<WithId<T>>
Enter fullscreen mode Exit fullscreen mode
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>>
Enter fullscreen mode Exit fullscreen mode
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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

Note: The list getter returns documents in the order specified by your query's orderBy clause, not insertion order. Use items for 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>;
}
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Read Patterns: useDocument and useCollection with automatic subscription lifecycle
  2. Reactive Queries: Dynamic re-subscription when filter state changes
  3. Authentication: Singleton authState with init-once pattern
  4. Live Objects: Auto-syncing documents via Deep Path Proxy
  5. Advanced: Optimistic updates, global sync tracking, error handling
  6. 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 LiveCollection with LiveModel
  • Full control: Use native Firestore APIs with $effect for cleanup

Happy building! 🔥

Top comments (0)