SSR & RSC Hydration Boundaries

Rendering a query on the server and then handing it to the browser sounds like it should be free, but two failure modes appear the moment you try it: the markup React streams down does not match what the client re-renders (a hydration mismatch), or the client discards the server’s work and re-fetches everything on mount (a refetch waterfall). Both stem from the same root problem — the server and client each hold their own React Query cache, and unless you deliberately serialize one and rehydrate it into the other, they diverge. This guide is part of Cache Hydration & Persistence Strategies, and it pairs closely with Offline Cache Persistence, which solves the analogous problem of moving a cache across time (page reloads) rather than across the network (server to client).

The mechanism React Query v5 gives you is a three-part pipeline: prefetchQuery warms a server-side cache, dehydrate() serializes it into a plain JSON payload, and <HydrationBoundary state={...}> injects that payload into the client cache before any component mounts. Get the pipeline right and the browser paints server data instantly and treats it as fresh; get staleTime wrong and the same data flickers as it refetches. The sections below build the pipeline from first principles, then show the exact Next.js App Router wiring where a server component prefetches and a client component consumes.


React Query server-to-client hydration pipeline Left column is the server: a request-scoped QueryClient is warmed by prefetchQuery, then dehydrate serializes it. The dehydrated state travels inside the streamed HTML across the network boundary in the centre. The right column is the client: HydrationBoundary injects the state into the browser QueryClient, and useQuery reads it with no refetch when staleTime has not elapsed. SERVER (per request) CLIENT (browser) NETWORK getQueryClient() · cache() await prefetchQuery(key) dehydrate(queryClient) → { queries: [ … ] } JSON <HydrationBoundary> state serialized into HTML streamed HTML dehydratedState + dataUpdatedAt browser QueryClient hydrate merges by key staleTime gate now − updatedAt < staleTime? useQuery(key) reads cache · no refetch fresh → paint · stale → background refetch
The dehydrated state rides inside the streamed HTML; on the client, the staleTime gate decides whether the hydrated query paints silently or triggers a background refetch.

Diagnostic Checklist

You are looking at an SSR hydration boundary problem if any of these are true:

  • The browser console logs Text content did not match. Server: "…" Client: "…" or Hydration failed because the initial UI does not match what was rendered on the server.
  • Every server-rendered query shows a loading spinner or fires a network request in the Network tab immediately after the page becomes interactive, despite the data being present in the HTML.
  • React Query DevTools shows a query flipping from fresh to fetching within milliseconds of first paint.
  • Two different users occasionally see each other’s server-rendered data — a symptom of a shared, non-request-scoped QueryClient on the server.
  • dehydrate() output is empty ({ queries: [] }) even though you called prefetchQuery, because you dehydrated a different QueryClient instance than the one you prefetched into.
  • A streamed Suspense chunk arrives and overwrites data the user already edited on the client.

Prerequisites

Before wiring hydration boundaries, make sure you are solid on:

  • The server/client state split: hydration only applies to server state — data owned by your API, not ephemeral UI state. If you are unsure which of your state belongs in the query cache at all, resolve that first in Client vs Server State Boundaries. Hydrating client-owned state (form drafts, toggles) into the query cache is a category error that produces mismatches.
  • Stable query keys: the server and client must use byte-identical query keys or HydrationBoundary cannot match the dehydrated entry to the useQuery call. Serialize keys deterministically as covered in Designing Stable Query Keys for React Query.
  • staleTime vs gcTime: staleTime controls when data is considered old enough to refetch; gcTime controls when an unused entry is evicted from memory. Hydration behavior hinges on staleTime.
  • React Query v5 APIs: prefetchQuery, dehydrate, HydrationBoundary (the v5 replacement for the old Hydrate component), and the object-form useQuery.

Implementation 1 — The Prefetch → Dehydrate → Hydrate Pipeline

The foundational pattern is framework-agnostic: warm a server-side cache, serialize it, and rehydrate it on the client. Everything else is a variation on this core.

Steps:

  1. Create a QueryClient on the server with a default staleTime above zero so hydrated entries are not instantly stale.
  2. await queryClient.prefetchQuery(...) for each query the initial view needs — prefetchQuery resolves the promise and writes the result into the cache but returns nothing to render.
  3. Call dehydrate(queryClient) to produce a serializable snapshot of every non-pending query.
  4. Wrap the client subtree in <HydrationBoundary state={dehydratedState}> so the browser cache adopts the snapshot before mount.
import {
  QueryClient,
  dehydrate,
  HydrationBoundary,
} from '@tanstack/react-query';

interface Article {
  id: string;
  title: string;
  body: string;
}

async function fetchArticle(id: string): Promise<Article> {
  const res = await fetch(`https://api.example.com/articles/${id}`);
  if (!res.ok) throw new Error(`Article ${id} failed: ${res.status}`);
  return res.json();
}

// Runs on the server for a single request.
export async function renderArticlePage(id: string) {
  const queryClient = new QueryClient({
    defaultOptions: {
      queries: {
        // Hydrated data stays fresh for a minute — no instant refetch on the client.
        staleTime: 60_000,
      },
    },
  });

  await queryClient.prefetchQuery({
    queryKey: ['article', id],
    queryFn: () => fetchArticle(id),
  });

  const state = dehydrate(queryClient);

  return (
    <HydrationBoundary state={state}>
      {/* client components that call useQuery(['article', id]) go here */}
    </HydrationBoundary>
  );
}

Cache Behavior Impact. prefetchQuery awaits the queryFn and stores the resolved value along with a dataUpdatedAt timestamp; unlike fetchQuery it swallows the return value, so it is purely a side-effect on the cache. dehydrate walks every query in the client and emits those with a success status (pending and error queries are excluded by default), preserving each dataUpdatedAt. When HydrationBoundary restores the entry on the client, React Query subtracts dataUpdatedAt from Date.now() and compares against staleTime — with staleTime: 60_000 the entry is fresh, so the mounting useQuery reads it synchronously and skips the network entirely.

Configuration Trade-offs:

  • A staleTime of 0 (the default) guarantees a refetch on every hydrated mount — correct if your data must be real-time, wasteful if it was just fetched a few hundred milliseconds ago on the server. Match staleTime to how long the server-rendered value is acceptable.
  • gcTime on the server client is almost irrelevant because the instance is discarded after the request; keep the default and set memory policy on the browser client instead.
  • Passing the wrong QueryClient to dehydrate (a fresh one instead of the prefetched one) yields an empty snapshot and silently falls back to client fetching. Always dehydrate the same instance you prefetched.
  • Setting staleTime: Infinity freezes hydrated data forever until a manual invalidateQueries — appropriate for immutable content like a published article, dangerous for anything a user can mutate.

Implementation 2 — The Next.js App Router Boundary

In the App Router, the server component and client component are separate files, and the QueryClient must be request-scoped to avoid leaking data between concurrent users. React’s cache() gives you exactly one instance per request. The full recipe lives in Hydrating React Query in the Next.js App Router; the summary below shows the shape.

Steps:

  1. Define getQueryClient with cache() so a server component and any nested server component in the same request share one instance.
  2. In the page (a server component), prefetch and dehydrate, then render <HydrationBoundary>.
  3. Put the actual data read in a separate client component ('use client') that calls useQuery with the same key.
  4. Keep the browser QueryClient in a provider mounted once at the root so hydration lands in a stable cache.
// get-query-client.ts
import { QueryClient } from '@tanstack/react-query';
import { cache } from 'react';

// One QueryClient per server request; a fresh call per browser navigation.
export const getQueryClient = cache(
  () =>
    new QueryClient({
      defaultOptions: { queries: { staleTime: 60_000 } },
    }),
);
// app/dashboard/page.tsx  (server component)
import { dehydrate, HydrationBoundary } from '@tanstack/react-query';
import { getQueryClient } from '../../get-query-client';
import { DashboardWidgets } from './dashboard-widgets';

async function fetchStats() {
  const res = await fetch('https://api.example.com/stats', {
    // avoid Next's fetch cache masking React Query staleness
    cache: 'no-store',
  });
  if (!res.ok) throw new Error('stats failed');
  return res.json() as Promise<{ active: number; total: number }>;
}

export default async function DashboardPage() {
  const queryClient = getQueryClient();

  await queryClient.prefetchQuery({
    queryKey: ['stats'],
    queryFn: fetchStats,
  });

  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <DashboardWidgets />
    </HydrationBoundary>
  );
}

Cache Behavior Impact. cache() memoizes getQueryClient for the lifetime of a single server request, so the prefetchQuery write and the dehydrate read hit the same instance without a module-level singleton that would bleed across requests. Because DashboardWidgets is a client component nested inside HydrationBoundary, React Query installs the dehydrated ['stats'] entry into the browser cache during the hydration pass — before DashboardWidgets first renders — so its useQuery(['stats']) observes a populated, fresh cache and never shows a loading state.

Configuration Trade-offs:

  • Wrapping the factory in cache() is the single line that prevents cross-request data leakage; a plain new QueryClient() at module scope on the server is a security bug, not a style choice.
  • Next.js’s fetch has its own cache layered under React Query. Using cache: 'no-store' (or a revalidate value you understand) keeps the two caches from fighting; otherwise a stale Next fetch cache can hide a staleTime you expect React Query to honor.
  • The browser-side provider must instantiate its QueryClient in a useState initializer (or module ref) so React does not recreate it on every render, which would drop the just-hydrated cache.
  • refetchOnWindowFocus on the browser client interacts with hydration: even with a generous staleTime, an immediate window-focus event after navigation can trigger a refetch. Disable it for dashboards that should trust server data until an explicit action.

Implementation 3 — Streaming with Suspense Boundaries

When a query is slow, you do not want it to block the entire shell. Streaming lets you paint the layout immediately and flush each query into the HTML as it resolves, with React Query v5 dehydrating pending queries so their promises resume on the client.

Steps:

  1. Kick off the prefetch without awaiting it, so the server does not block on the slow query.
  2. Wrap the consuming client component in a <Suspense> boundary with a fallback.
  3. Have the client component call useSuspenseQuery so it suspends until the streamed chunk arrives.
  4. Let React Query stream the dehydrated (initially pending, later resolved) query down the same response.
// app/feed/page.tsx  (server component)
import { dehydrate, HydrationBoundary } from '@tanstack/react-query';
import { Suspense } from 'react';
import { getQueryClient } from '../../get-query-client';
import { LiveFeed } from './live-feed';

async function fetchFeed() {
  const res = await fetch('https://api.example.com/feed');
  if (!res.ok) throw new Error('feed failed');
  return res.json() as Promise<Array<{ id: string; text: string }>>;
}

export default function FeedPage() {
  const queryClient = getQueryClient();

  // Note: NOT awaited — the shell renders while this resolves.
  queryClient.prefetchQuery({ queryKey: ['feed'], queryFn: fetchFeed });

  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <Suspense fallback={<p>Loading feed…</p>}>
        <LiveFeed />
      </Suspense>
    </HydrationBoundary>
  );
}
// app/feed/live-feed.tsx  (client component)
'use client';
import { useSuspenseQuery } from '@tanstack/react-query';

export function LiveFeed() {
  const { data } = useSuspenseQuery({
    queryKey: ['feed'],
    queryFn: async () => {
      const res = await fetch('/api/feed');
      if (!res.ok) throw new Error('feed failed');
      return res.json() as Promise<Array<{ id: string; text: string }>>;
    },
  });

  return (
    <ul>
      {data.map((post) => (
        <li key={post.id}>{post.text}</li>
      ))}
    </ul>
  );
}

Cache Behavior Impact. Because prefetchQuery is not awaited, dehydrate captures the ['feed'] query in a pending state; React Query v5’s streaming dehydration serializes that pending query and its in-flight promise into the response. As the server resolves the feed, the resolved data is flushed to the client and the pending entry transitions to success in place, resuming the suspended useSuspenseQuery without a fresh client request. The Suspense fallback covers only the feed subtree, so the surrounding shell is interactive while the feed streams in.

Configuration Trade-offs:

  • Streaming trades a fully-populated first paint for a faster shell. If SEO requires the feed content in the initial HTML, await the prefetch instead and accept the slower time-to-first-byte.
  • useSuspenseQuery never returns an undefined data state, which simplifies the component but means you must have a Suspense boundary or the whole tree suspends — pair every useSuspenseQuery with an explicit <Suspense>.
  • With streaming, a very short staleTime can still trigger a client refetch after the streamed chunk lands; keep staleTime above the streaming window so the resolved data is treated as fresh.
  • Streaming pending queries increases the serialized payload size incrementally; for many concurrent slow queries, measure the total streamed bytes against blocking on a subset of critical queries instead.

Common Pitfalls & Resolutions

Observable Issue Root Cause Diagnostic Resolution
Hydrated query refetches instantly on the client staleTime is 0, so the entry is stale the moment dataUpdatedAt is compared against Date.now() on mount Set staleTime above the server-to-client transfer window (30–60s) on the query or as a defaultOptions default; verify in DevTools that the entry shows fresh after hydration
Concurrent users see each other’s server data A module-level QueryClient shared across requests on the long-lived server accumulates every user’s data into one cache Wrap the factory in React cache() (App Router) or create a new QueryClient inside the request handler; never export a singleton
dehydrate() returns { queries: [] } The dehydrated instance is not the one that was prefetched, or the prefetch was not awaited Confirm getQueryClient() returns the same instance for prefetch and dehydrate; await the prefetchQuery unless you are intentionally streaming
Text content did not match warning on hydration Query data or a value derived from it (a date, a random ID) differs between server and client render Make the render deterministic — see Avoiding Hydration Mismatch Errors; do not compute Date.now() or Math.random() in the render path
Streamed chunk overwrites locally edited data A late Suspense chunk carries an older dataUpdatedAt than a client mutation already applied Rely on HydrationBoundary’s newer-wins merge; ensure optimistic updates bump dataUpdatedAt via setQueryData so they are not clobbered

Frequently Asked Questions

Why does my hydrated query refetch immediately on the client even though the server already fetched it?

A dehydrated query carries a dataUpdatedAt timestamp. On the client, HydrationBoundary restores that timestamp, and React Query compares elapsed time against staleTime. If staleTime is 0 (the default), the query is stale the instant it hydrates and the mount triggers a background refetch. Set a staleTime greater than the expected server-to-client transfer time — typically 30 to 60 seconds — so the hydrated data is considered fresh and no refetch fires. You can set it per-query or as a defaultOptions.queries.staleTime on the server QueryClient.

Should I create one QueryClient for the whole server or one per request?

One per request. A module-level QueryClient shared across requests leaks one user’s data into another user’s dehydrated payload because the server process is long-lived and the cache accumulates. In the App Router, wrap the factory in React’s cache() so each server request gets a fresh, request-scoped instance that is discarded when the request completes. Never export a singleton QueryClient from a module that server components import.

Can I dehydrate a query that is still pending when the server renders?

In React Query v5 you can, because dehydrate serializes pending queries when streaming is used, allowing the in-flight promise to resume on the client. Outside a streaming context, a pending query has no data to transfer and hydrating it produces an undefined result that immediately refetches. Use await queryClient.prefetchQuery so the query resolves before dehydrate runs, or opt into streaming with a Suspense boundary that flushes the resolved query as it settles.

Does HydrationBoundary overwrite queries that already exist in the client cache?

It merges rather than blindly overwrites. HydrationBoundary compares the dehydrated dataUpdatedAt against any existing client entry for the same query key and only adopts the incoming data if it is newer. This prevents a late-arriving streamed chunk from clobbering data the user has already mutated locally, but it also means a stale server payload will be ignored if the client already holds fresher data. If you need the server value to win unconditionally, call queryClient.setQueryData explicitly after hydration.


  • Cache Hydration & Persistence Strategies — the parent section covering every technique for moving a cache across the network and across page reloads, of which SSR hydration is the server-to-client half.
  • Offline Cache Persistence — the sibling approach that serializes the cache to storage so it survives reloads and offline sessions, using the same dehydrate/hydrate primitives against a different transport.
  • Hydrating React Query in the Next.js App Router — the exact file-by-file recipe for a request-scoped getQueryClient, server prefetch, and client useQuery with no refetch flash.
  • Avoiding Hydration Mismatch Errors — how non-deterministic cache values cause React’s “Text content did not match” errors and how to exclude pending queries from dehydration.
  • Client vs Server State Boundaries — which state belongs in the query cache at all, so you never try to hydrate client-owned UI state and trigger a mismatch.