React Query Hydration: Dehydrate and Hydrate for SSR/SSG
Quick Answer
- What it does:
dehydrate()serializes a QueryClient's cache on the server;hydrate()or<HydrationBoundary>restores it on the client, preventing redundant network requests after SSR/SSG. - First check: Ensure you're using
@tanstack/react-queryv5+ and importingdehydrate/hydratefrom the correct package (not the legacyreact-query). - Minimal setup: Create a new
QueryClientper request,prefetchQueryyour data, calldehydrate(queryClient), then pass the state to<HydrationBoundary state={dehydratedState}>wrapping your client components. - Version boundary: This API is stable in
@tanstack/react-queryv5+. In v4, the same functions existed underreact-querybut with slightly different signatures. - Critical limitation: By default, only successful queries are dehydrated. To include errors or mutations, you must configure
shouldDehydrateQueryandshouldDehydrateMutation.
What Problem It Solves
In server-rendered frameworks like Next.js App Router or Remix, you fetch data on the server to generate HTML. Without hydration, the client would re-fetch all that data immediately after load, wasting bandwidth and slowing time-to-interactive.
React Query's dehydration/hydration pattern serializes the server-side query cache into a plain JSON object, sends it to the client, and restores it into the client-side QueryClient. The client sees the data as already cached and fresh, so it skips the initial network request.
This is essential for:
- SEO-critical pages (product listings, blog articles)
- Dashboard views with many parallel queries
- Any page where first-load performance matters
Parameters and Environment Variables
dehydrate(queryClient, options?)
| Parameter | Required | Description |
|---|---|---|
client | Yes | The QueryClient instance to serialize |
options | No | DehydrateOptions object (see below) |
DehydrateOptions:
| Option | Type | Default | Description |
|---|---|---|---|
shouldDehydrateMutation | (mutation) => boolean | () => false | Filter which mutations to include |
shouldDehydrateQuery | (query) => boolean | (query) => query.state.status === 'success' | Filter which queries to include |
serializeData | (data) => any | identity | Custom serializer for non-JSON-safe data (Date, Error, etc.) |
shouldRedactErrors | (error) => boolean | () => false | Filter which errors to include in dehydrated state |
hydrate(queryClient, dehydratedState, options?)
| Parameter | Required | Description |
|---|---|---|
client | Yes | The QueryClient to restore state into |
dehydratedState | Yes | The state object from dehydrate() |
options | No | HydrateOptions (currently only defaultOptions) |
<HydrationBoundary>
| Prop | Required | Description |
|---|---|---|
state | Yes | The dehydrated state object |
defaultOptions | No | Default query/mutation options for hydrated queries |
queryClient | No | Custom QueryClient instance (uses nearest context by default) |
Minimal Working Configuration
Next.js App Router (Server Component → Client Component)
TSX// app/page.tsx (Server Component) import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query' import { getPosts } from './api' import PostsList from './PostsList' export default async function Home() { const queryClient = new QueryClient() await queryClient.prefetchQuery({ queryKey: ['posts'], queryFn: getPosts, staleTime: 60000, // prevent immediate refetch on client }) const dehydratedState = dehydrate(queryClient) return ( <HydrationBoundary state={dehydratedState}> <PostsList /> </HydrationBoundary> ) }
TSX// app/PostsList.tsx (Client Component) 'use client' import { useQuery } from '@tanstack/react-query' import { getPosts } from './api' export default function PostsList() { const { data } = useQuery({ queryKey: ['posts'], queryFn: getPosts, // won't execute if data is hydrated }) return <div>{/* render data */}</div> }
Remix / Other SSR Frameworks
TSXimport { dehydrate, hydrate, QueryClient, QueryClientProvider } from '@tanstack/react-query' // On the server const queryClient = new QueryClient() await queryClient.prefetchQuery({ queryKey: ['items'], queryFn: fetchItems }) const dehydratedState = dehydrate(queryClient) // On the client (in root component) const queryClient = new QueryClient() hydrate(queryClient, dehydratedState) function App() { return ( <QueryClientProvider client={queryClient}> <YourApp /> </QueryClientProvider> ) }
Root Cause Analysis
The hydration mechanism works through a simple merge strategy:
-
Server: Queries are fetched and stored in the QueryClient cache.
dehydrate()iterates over all queries (and optionally mutations), serializes their state (data, status, error, dataUpdatedAt), and returns a plain object. -
Client:
hydrate()or<HydrationBoundary>receives this object. For each query in the dehydrated state, it checks if the client-side cache already has that query. If not, it adds the query. If it does, it comparesdataUpdatedAttimestamps—the newer data wins.
This means:
- If the client already fetched the same query with a newer timestamp, the server data is ignored.
- If the server data is newer, it replaces the client cache.
- Queries not present in the dehydrated state remain untouched.
Common Errors and Fixes
Hydration failed because the initial UI does not match what was rendered on the server
Cause: The server-rendered HTML differs from the first client render. This is usually a React hydration error, not a React Query error, but can be triggered if your component conditionally renders based on query data that wasn't properly dehydrated.
Fix: Ensure your server and client component trees are identical. Use suppressHydrationWarning on elements that intentionally differ (e.g., timestamps), or move client-only logic into useEffect.
dehydrate is not a function / hydrate is not a function
Cause: Wrong import path or outdated package version.
Fix:
BASHnpm install @tanstack/react-query@latest
Then import correctly:
TSXimport { dehydrate, hydrate } from '@tanstack/react-query'
Do not import from react-query (the legacy package).
Cannot read properties of undefined (reading 'getQueryData')
Cause: dehydrate() was called before any queries were prefetched, or the QueryClient wasn't properly initialized.
Fix: Always create a new QueryClient per request and ensure prefetchQuery completes before calling dehydrate:
TSXconst queryClient = new QueryClient() await queryClient.prefetchQuery({ queryKey: ['data'], queryFn: fetchData }) const state = dehydrate(queryClient) // safe now
Maximum call stack size exceeded during serialization
Cause: Circular references or extremely large objects in your query data.
Fix: Use the serializeData option to strip or transform problematic data:
TSXconst state = dehydrate(client, { serializeData: (data) => { // Remove circular references or truncate large arrays return JSON.parse(JSON.stringify(data, getCircularReplacer())) }, })
Production Notes and Security Checks
-
Per-request QueryClient: Always create a new
QueryClientfor each server request. Sharing a single instance across requests can leak data between users and cause race conditions. -
Filter sensitive data: By default, only successful queries are dehydrated. If you enable
shouldDehydrateQuery: () => true, you may expose error messages or partial data. UseshouldRedactErrorsto strip sensitive information from errors. -
Stale time matters: Set
staleTimeon your prefetched queries to prevent the client from immediately refetching. A value of 30-60 seconds is usually safe for most data. -
Serialization limits: Web Storage API (localStorage/sessionStorage) has a ~5MB limit. If you're persisting dehydrated state to storage, keep the payload small by filtering unnecessary queries.
-
Cross-tab synchronization: If using persistent cache (e.g.,
@tanstack/query-persist-client-core), be aware that multiple tabs hydrating the same storage key can overwrite each other's state.
FAQ
Q: How do I use HydrationBoundary in Next.js App Router?
A: In your server component, create a QueryClient, prefetch queries, dehydrate, and wrap your client components:
TSX// app/page.tsx import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query' import { getPosts } from './api' import PostsList from './PostsList' export default async function Home() { const queryClient = new QueryClient() await queryClient.prefetchQuery({ queryKey: ['posts'], queryFn: getPosts }) const dehydratedState = dehydrate(queryClient) return ( <HydrationBoundary state={dehydratedState}> <PostsList /> </HydrationBoundary> ) }
Q: How do I customize serialization for Date or Error objects?
A: Use serializeData in dehydrate and deserializeData in hydrate:
TSXconst state = dehydrate(client, { serializeData: (data) => { if (data instanceof Date) return { __type: 'Date', value: data.toISOString() } if (data instanceof Error) return { __type: 'Error', message: data.message } return data }, }) hydrate(client, state, { deserializeData: (data) => { if (data?.__type === 'Date') return new Date(data.value) if (data?.__type === 'Error') return new Error(data.message) return data }, })
Q: Why does the client still make a network request after hydration?
A: Three common causes:
staleTimeis 0 (default): The client considers the data stale immediately. SetstaleTimeon your prefetch (e.g.,staleTime: 60000).- Query key mismatch: The server and client must use identical
queryKeyvalues. - Client already has newer data: If the client fetched the same query before hydration, the server data (with an older timestamp) is ignored. Ensure hydration happens before any client-side queries run.