React Query Hydration: Dehydrate and Hydrate for SSR/SSG

Topic: react-query-hydration-boundary-mismatchUpdated 7/23/2026

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-query v5+ and importing dehydrate/hydrate from the correct package (not the legacy react-query).
  • Minimal setup: Create a new QueryClient per request, prefetchQuery your data, call dehydrate(queryClient), then pass the state to <HydrationBoundary state={dehydratedState}> wrapping your client components.
  • Version boundary: This API is stable in @tanstack/react-query v5+. In v4, the same functions existed under react-query but with slightly different signatures.
  • Critical limitation: By default, only successful queries are dehydrated. To include errors or mutations, you must configure shouldDehydrateQuery and shouldDehydrateMutation.

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?)

ParameterRequiredDescription
clientYesThe QueryClient instance to serialize
optionsNoDehydrateOptions object (see below)

DehydrateOptions:

OptionTypeDefaultDescription
shouldDehydrateMutation(mutation) => boolean() => falseFilter which mutations to include
shouldDehydrateQuery(query) => boolean(query) => query.state.status === 'success'Filter which queries to include
serializeData(data) => anyidentityCustom serializer for non-JSON-safe data (Date, Error, etc.)
shouldRedactErrors(error) => boolean() => falseFilter which errors to include in dehydrated state

hydrate(queryClient, dehydratedState, options?)

ParameterRequiredDescription
clientYesThe QueryClient to restore state into
dehydratedStateYesThe state object from dehydrate()
optionsNoHydrateOptions (currently only defaultOptions)

<HydrationBoundary>

PropRequiredDescription
stateYesThe dehydrated state object
defaultOptionsNoDefault query/mutation options for hydrated queries
queryClientNoCustom 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

TSX
import { 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:

  1. 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.

  2. 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 compares dataUpdatedAt timestamps—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:

BASH
npm install @tanstack/react-query@latest

Then import correctly:

TSX
import { 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:

TSX
const 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:

TSX
const state = dehydrate(client, {
  serializeData: (data) => {
    // Remove circular references or truncate large arrays
    return JSON.parse(JSON.stringify(data, getCircularReplacer()))
  },
})

Production Notes and Security Checks

  1. Per-request QueryClient: Always create a new QueryClient for each server request. Sharing a single instance across requests can leak data between users and cause race conditions.

  2. Filter sensitive data: By default, only successful queries are dehydrated. If you enable shouldDehydrateQuery: () => true, you may expose error messages or partial data. Use shouldRedactErrors to strip sensitive information from errors.

  3. Stale time matters: Set staleTime on your prefetched queries to prevent the client from immediately refetching. A value of 30-60 seconds is usually safe for most data.

  4. 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.

  5. 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:

TSX
const 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:

  1. staleTime is 0 (default): The client considers the data stale immediately. Set staleTime on your prefetch (e.g., staleTime: 60000).
  2. Query key mismatch: The server and client must use identical queryKey values.
  3. 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.

Related Guides