Server-Side Rendering (SSR) improves page load times. However, building highly interactive experiences in SSR frameworks can be difficult. This is especially true for Firestore, where fetching data on the server and then starting a real-time listener on the client can be tricky to implement. If not done carefully, it can cause visual layout flickers or duplicate database reads.

To help with this, the Firebase JS SDK provides SSR-specific APIs that simplify the process. By serializing query results on the server and resuming them on the client, you can build fast server-rendered pages that avoid duplicate reads and visual flickers, while maintaining client-side synchronization.

Let’s build a sample Next.js app that can get the page-load performance of fetching data from Firestore on the server, while still benefiting from realtime updates on the client.

Server-Side Rendering (SSR) with Next.js

Next.js requires you to serialize data passed from Server Components to Client Components. Use the Firestore snapshot’s toJSON() method to do this.

app/page.tsx
import { initializeServerApp } from 'firebase/app';
import { cookies, headers } from 'next/headers';
import { getFirestore, getDocs, collection, query } from 'firebase/firestore';
import { firebaseConfig } from '@/lib/firebaseConfig';
import ClientPostsList from './ClientPostsList';

export default async function Page() {
  const cookieStore = await cookies();
  const token = cookieStore.get('__session')?.value;
  const headersObj = await headers();
  const appCheckToken = headersObj.get('X-Firebase-AppCheck') || undefined;

  // Initialize server app
  const serverApp = initializeServerApp(firebaseConfig, {
    releaseOnDeref: headersObj,
    authIdToken: token,
    appCheckToken: appCheckToken,
  });
  const db = getFirestore(serverApp);

  const postsQuery = query(collection(db, 'posts'));
  const querySnapshot = await getDocs(postsQuery);

  // Serialize the snapshot to JSON
  const serializedSnapshot = querySnapshot.toJSON();

  return (
    <main>
      <h1>Latest Posts</h1>
      <ClientPostsList serializedSnapshot={serializedSnapshot} />
    </main>
  );
}
Copied!

Client Components

Once the client receives a serialized snapshot as a prop, it can deserialize it to render data immediately, and resume a realtime listener to dynamically re-render as new updates are received.

Deserialize a snapshot

The querySnapshotFromJSON function restores a serialized QuerySnapshot on the client. This allows you to use standard snapshot methods, such as .data(), during the first render.

app/ClientPostsList.tsx
'use client';

import { initializeApp } from 'firebase/app';
import { getFirestore, querySnapshotFromJSON } from 'firebase/firestore';
import { firebaseConfig } from '@/lib/firebaseConfig';

const app = initializeApp(firebaseConfig);
const db = getFirestore(app);

export default function ClientPostsList({ serializedSnapshot }: { serializedSnapshot: object }) {
  const snapshot = querySnapshotFromJSON(db, serializedSnapshot);

  return (
    <ul>
      {snapshot.docs.map(doc => (
        <li key={doc.id}>
          <h2>{doc.data().title}</h2>
          <p>{doc.data().content}</p>
        </li>
      ))}
    </ul>
  );
}
Copied!

Resume a realtime listener

To receive live updates after the initial render, use onSnapshotResume. In React, call querySnapshotFromJSON during the initial render, and call onSnapshotResume inside a useEffect hook.

app/ClientPostsList.tsx
'use client';

import { useState, useEffect } from 'react';
import { initializeApp } from 'firebase/app';
import { getFirestore, querySnapshotFromJSON, onSnapshotResume, QuerySnapshot, DocumentData } from 'firebase/firestore';
import { firebaseConfig } from '@/lib/firebaseConfig';

const app = initializeApp(firebaseConfig);
const db = getFirestore(app);

export default function ClientPostsList({ serializedSnapshot }: { serializedSnapshot: object }) {
  const [snapshot, setSnapshot] = useState<QuerySnapshot<DocumentData>>(() => 
    querySnapshotFromJSON<DocumentData>(db, serializedSnapshot)
  );

  useEffect(() => {
      // Resume the real-time listener
      const unsubscribe = onSnapshotResume<DocumentData>(
        db,
        serializedSnapshot,
        {
          next: (newSnapshot) => setSnapshot(newSnapshot),
          error: (error) => console.error("Sync error:", error)
        }
      );

      return () => unsubscribe();
  }, [serializedSnapshot]);

  return (
    <ul>
      {snapshot.docs.map(doc => (
        <li key={doc.id}>
          <h2>{doc.data().title}</h2>
          <p>{doc.data().content}</p>
        </li>
      ))}
    </ul>
  );
}
Copied!

A reusable hook

Here is a custom hook, useSerializedQuery, to simplify this logic. It handles restoring the snapshot, listening for updates, tracking status, and handling errors.

hooks/useSerializedQuery.ts
import { useState, useEffect } from 'react';
import { Firestore, QuerySnapshot, querySnapshotFromJSON, onSnapshotResume, FirestoreError, DocumentData } from 'firebase/firestore';

export function useSerializedQuery(
db: Firestore, 
serializedSnapshot: object
): { snapshot: QuerySnapshot<DocumentData>; status: 'rehydrated' | 'live'; error: FirestoreError | null } {

  const [snapshot, setSnapshot] = useState<QuerySnapshot<DocumentData>>(() => 
    querySnapshotFromJSON<DocumentData>(db, serializedSnapshot)
  );
  const [status, setStatus] = useState<'rehydrated' | 'live'>('rehydrated');
  const [error, setError] = useState<FirestoreError | null>(null);

  useEffect(() => {
      const unsubscribe = onSnapshotResume<DocumentData>(
        db,
        serializedSnapshot,
        {
          next: (newSnapshot) => {
              setSnapshot(newSnapshot);
              setStatus('live');
          },
          error: (err) => setError(err)
        }
      );

      return () => unsubscribe();
  }, [db, serializedSnapshot]);

  return { snapshot, status, error };
}
Copied!

Now, your Client Component looks like this:

app/ClientPostsList.tsx
'use client';

import { initializeApp } from 'firebase/app';
import { getFirestore } from 'firebase/firestore';
import { firebaseConfig } from '@/lib/firebaseConfig';
import { useSerializedQuery } from '@/hooks/useSerializedQuery';

const app = initializeApp(firebaseConfig);
const db = getFirestore(app);

export default function ClientPostsList({ serializedSnapshot }: { serializedSnapshot: object }) {
  const { snapshot, status, error } = useSerializedQuery(db, serializedSnapshot);

  if (error) {
    throw error; // Allow a parent Error Boundary to handle serialization, hydration, or sync failures
  }

  return (
    <div>
      <small>Sync Status: {status}</small>
      <ul>
        {snapshot.docs.map(doc => (
          <li key={doc.id}>
            <h2>{doc.data().title}</h2>
            <p>{doc.data().content}</p>
          </li>
        ))}
      </ul>
    </div>
  );
}
Copied!

Serialization trade-offs and alternatives

Serialization is a great option for web apps that need realtime updates, but can result in heavier pages. It also introduces complexity and bundle size that isn’t necessary for parts of a web app where data doesn’t change often.

Payload size

The serialized snapshot from toJSON() is embedded directly in the HTML document. Large queries or bulky fields will bloat your page. Keep the initial dataset small by using limit().

When realtime isn’t needed

If your page does not require real-time updates, skip serialization. Instead, fetch the data and render it in a Server Component. This keeps the client bundle and HTML payload minimal.

When data doesn’t change often

For static or slow-changing shared data, use data bundles to minimize reads.

Conclusion

By leveraging querySnapshotFromJSON and onSnapshotResume, you can build fast server-rendered views that avoid duplicate reads and visual flickers, while maintaining real-time client-side synchronization.

If you’re inspired to try this out, let us know how it went on X or LinkedIn!