Hydrating React Query in the Next.js App Router

The App Router splits your UI into server components that run once per request and client components that run in the browser, and React Query has to bridge that split: the server fetches, the browser consumes. Done wrong, the browser ignores the server’s work and every useQuery flashes a spinner before re-fetching data that was already in the HTML. This page is the exact, copy-ready recipe — a cache()-scoped getQueryClient, a server component that prefetches and dehydrates, and a client component that reads the result — that eliminates the refetch flash. It is a concrete recipe within SSR & RSC Hydration Boundaries; if your problem is React throwing Text content did not match rather than a redundant refetch, start instead with Avoiding Hydration Mismatch Errors.

The whole trick rests on two instances that must not be confused: a short-lived server QueryClient created fresh for each request, and a long-lived browser QueryClient created once. The server one exists only to be prefetched and dehydrated; the browser one is where HydrationBoundary deposits that dehydrated snapshot before your components mount.

Diagnostic Checklist

Confirm your symptoms match this recipe before applying it:

  • Server components render data correctly, but the equivalent client component shows isLoading: true on first paint and fires a network request visible in DevTools.
  • React Query DevTools shows the query as fetching immediately after navigation, then success a moment later — a redundant round trip.
  • You see a warning about a QueryClient being recreated on every render, or hydrated data disappearing after the first client render.
  • Two requests in flight simultaneously occasionally return each other’s prefetched data — the server QueryClient is a module singleton, not request-scoped.

App Router prefetch and hydration wiring A server component calls getQueryClient wrapped in cache, awaits prefetchQuery, and passes dehydrate output into HydrationBoundary. The boundary sits above a client component that calls useQuery with the same key. A separate root provider holds the browser QueryClient that receives the hydrated state. page.tsx · SERVER COMPONENT const qc = getQueryClient() · cache() await qc.prefetchQuery(['todos']) <HydrationBoundary state={dehydrate(qc)}> CLIENT COMPONENT useQuery(['todos']) reads hydrated cache · no refetch dehydrate → JSON providers.tsx · ROOT (client) useState(() => new QueryClient()) created once · never recreated <QueryClientProvider> browser cache · receives hydration merge by key
The server component prefetches and dehydrates; HydrationBoundary deposits the snapshot into the browser QueryClient held by the root provider, where the client component reads it.

Step-by-Step Implementation

Step 1 — Build a request-scoped getQueryClient

The server is long-lived, so a QueryClient at module scope would accumulate every request’s data. Wrapping the factory in React’s cache() scopes it to a single request and gives the same instance to every server component in that request’s tree.

// lib/get-query-client.ts
import { QueryClient } from '@tanstack/react-query';
import { cache } from 'react';

// cache() returns one memoized instance per server request.
export const getQueryClient = cache(
  () =>
    new QueryClient({
      defaultOptions: {
        queries: {
          // Keep hydrated data fresh so the client does not refetch on mount.
          staleTime: 60_000,
        },
      },
    }),
);

Cache Behavior Analysis. cache() memoizes the factory by the current React request context, so calling getQueryClient() from the page and again from a nested server component returns the identical instance — the prefetch and the dehydrate therefore operate on the same cache. When the request ends, the instance is dropped, so no data survives into the next request. The staleTime: 60_000 default is what later lets the client treat hydrated entries as fresh.

Step 2 — Mount the browser QueryClient once at the root

The browser needs a single, stable QueryClient that lives for the whole session. Creating it in a useState initializer guarantees React never rebuilds it across re-renders, which would discard the hydrated cache.

// app/providers.tsx
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useState, type ReactNode } from 'react';

export function Providers({ children }: { children: ReactNode }) {
  // Lazy initializer: one QueryClient for the browser session.
  const [queryClient] = useState(
    () =>
      new QueryClient({
        defaultOptions: { queries: { staleTime: 60_000 } },
      }),
  );

  return (
    <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
  );
}

Cache Behavior Analysis. The useState(() => new QueryClient()) form runs the initializer exactly once for the component’s lifetime; a bare const queryClient = new QueryClient() would allocate a new cache on every render and silently drop hydrated data. This browser instance is the target that HydrationBoundary writes into — it must exist above every route that hydrates, so mount Providers in the root layout.

Step 3 — Prefetch in the server component and wrap in HydrationBoundary

Now the page: get the request-scoped client, await the prefetch so the data is present before serialization, then pass dehydrate(queryClient) into HydrationBoundary around the client subtree.

// app/todos/page.tsx  (server component)
import { dehydrate, HydrationBoundary } from '@tanstack/react-query';
import { getQueryClient } from '../../lib/get-query-client';
import { TodoList } from './todo-list';

interface Todo {
  id: string;
  title: string;
  done: boolean;
}

async function fetchTodos(): Promise<Todo[]> {
  const res = await fetch('https://api.example.com/todos', { cache: 'no-store' });
  if (!res.ok) throw new Error(`todos failed: ${res.status}`);
  return res.json();
}

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

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

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

Cache Behavior Analysis. prefetchQuery resolves fetchTodos and stores the array under ['todos'] with a dataUpdatedAt stamp, returning nothing to render. dehydrate(queryClient) then emits a JSON snapshot of that successful query — including its timestamp — which Next.js serializes into the streamed HTML. Because the snapshot is produced from the same request-scoped instance the prefetch wrote to, ['todos'] is guaranteed to be present in the dehydrated state.

Step 4 — Read the hydrated cache from a client component

The client component calls useQuery with the identical key. Because HydrationBoundary has already installed the entry into the browser cache, the hook reads it synchronously.

// app/todos/todo-list.tsx  (client component)
'use client';
import { useQuery } from '@tanstack/react-query';

interface Todo {
  id: string;
  title: string;
  done: boolean;
}

async function fetchTodos(): Promise<Todo[]> {
  const res = await fetch('/api/todos');
  if (!res.ok) throw new Error(`todos failed: ${res.status}`);
  return res.json();
}

export function TodoList() {
  const { data, isLoading } = useQuery({
    queryKey: ['todos'],
    queryFn: fetchTodos,
    staleTime: 60_000,
  });

  if (isLoading) return <p>Loading…</p>; // not reached on first paint when hydrated

  return (
    <ul>
      {data?.map((todo) => (
        <li key={todo.id}>{todo.title}</li>
      ))}
    </ul>
  );
}

Cache Behavior Analysis. On mount, useQuery(['todos']) finds the hydrated entry already in the browser cache with a dataUpdatedAt a fraction of a second old. Since now − dataUpdatedAt is well under the staleTime of 60 seconds, React Query marks the entry fresh and returns its data immediately — isLoading is false on the very first render, so the Loading… branch never shows and no network request fires. The queryFn is retained only for later background refetches once the entry goes stale.


Edge Cases and Gotchas

Request-scoped client to prevent cross-request leakage

If you replace cache(() => new QueryClient()) with a module-level export const queryClient = new QueryClient(), the server keeps one cache for the life of the process. Two users hitting /todos at once share that cache, and one user’s dehydrated payload can contain the other’s data. The cache() wrapper is not optional — it is the isolation boundary.

// WRONG: module singleton shared across every request on the server
export const queryClient = new QueryClient(); // leaks data between users

// RIGHT: cache() gives one instance per request
export const getQueryClient = cache(() => new QueryClient());

staleTime greater than zero so hydrated queries stay fresh

A staleTime of 0 means the hydrated entry is stale the instant it lands, so mount triggers a background refetch — the refetch flash you were trying to avoid. Set staleTime above the realistic server-to-client delay.

new QueryClient({
  defaultOptions: { queries: { staleTime: 60_000 } }, // hydrated data stays fresh 60s
});

Streaming with Suspense for slow queries

If one query is slow, do not await it — let the shell paint and stream the query in. Skip the await, wrap the consumer in <Suspense>, and read it with useSuspenseQuery so React Query v5 streams the pending-then-resolved query down the same response.

// Do not await — the shell renders while this resolves and streams in.
queryClient.prefetchQuery({ queryKey: ['report'], queryFn: fetchReport });

return (
  <HydrationBoundary state={dehydrate(queryClient)}>
    <Suspense fallback={<p>Building report…</p>}>
      <Report />
    </Suspense>
  </HydrationBoundary>
);

Common Pitfalls and Resolutions

Observable Issue Root Cause Diagnostic Resolution
Client useQuery shows a spinner and refetches despite server prefetch Query key mismatch between server prefetchQuery and client useQuery, or missing HydrationBoundary wrapper Log both keys and compare exactly; confirm the client component is a descendant of HydrationBoundary
Hydrated data flashes then reloads staleTime is 0, so the restored dataUpdatedAt is immediately considered stale on mount Set staleTime above zero (30–60s) on the server client defaults and the useQuery
Occasional cross-user data in server-rendered HTML Module-level QueryClient on the long-lived server shared across concurrent requests Wrap the factory in React cache() so each request gets an isolated instance
Hydrated cache vanishes after first client render Browser QueryClient recreated on every render instead of once Instantiate it in a useState(() => new QueryClient()) initializer inside the client provider

Frequently Asked Questions

Do I need both a server getQueryClient and a browser QueryClient?

Yes, and they are different instances in different runtimes. The server getQueryClient is created per request with cache() and exists only to prefetch and dehydrate. The browser QueryClient is created once in a client provider and is the long-lived cache that HydrationBoundary rehydrates into. You cannot share one instance across the two environments because the server and browser are separate JavaScript runtimes that never share memory.

Why does my useQuery still show a loading state despite prefetching?

The two usual causes are a query key mismatch between the server prefetch and the client useQuery, or forgetting to wrap the client component in HydrationBoundary. If the keys differ by even one serialized value — a number versus a string, an extra field — HydrationBoundary cannot match the dehydrated entry to the hook, so useQuery starts empty and fetches. Log both keys and compare them character by character, and confirm the component is inside the boundary.

How do I stop hydrated queries from refetching the moment they mount?

Set a staleTime greater than zero on the server QueryClient defaults (and matching it on the client is good hygiene). Dehydration preserves each query’s dataUpdatedAt, and on mount React Query treats the entry as fresh while now − dataUpdatedAt is under staleTime. With the default staleTime of zero the hydrated entry is stale immediately, so the mount schedules a background refetch that produces the flash you are seeing.