TanStack Query 15 ⚛️ SSR & Hydration — Server-side rendering and state transfer
Server-side rendering with TanStack Query is not a different API. It is the same useQuery and useMutation hooks, running in a different environment. The server prefetches the data, serializes the cache, and sends it to the client inside the HTML. The client creates a QueryClient, hydrates it with the serialized state, and the components render the data immediately—no loading spinner, no second request. The previous chapters covered the client-side patterns; this chapter covers the mechanism that transfers the cache from the server to the client.
The mechanism has three steps: prefetch on the server with queryClient.prefetchQuery, dehydrate the cache with dehydrate(queryClient), and hydrate on the client with <HydrationBoundary state={dehydratedState}> . The dehydrate function converts the cache into a plain, serializable object. The HydrationBoundary component restores it into the client’s QueryClient before the components render . The result is a page that arrives fully rendered, with the data already in the cache, and a client that does not fetch again until the data becomes stale.
Key point: The QueryClient on the server must be created per request, never at module scope. A module-scope client is shared across all requests, and one user’s data leaks into another user’s HTML . The client on the browser can be a singleton, but it must be created with useState or a module-level variable that survives React’s initial suspension . The staleTime must be greater than zero in SSR, or the client refetches immediately after hydration, which defeats the purpose of the server prefetch .
Why SSR requires the dehydrate/hydrate pattern
The request-isolation problem.
The server handles many requests concurrently. If the QueryClient is created once at module scope, every request shares the same cache, and the prefetched data from one request is visible to the next. The solution is to create a new QueryClient inside the request handler or the Server Component . The browser client can be a singleton because it belongs to a single user, but the server client must be per-request.
The serialization problem.
The cache contains the queries and their data, but the data is not automatically serializable. The dehydrate function extracts the successful queries by default and converts them into a format that can be embedded in the HTML . The default behavior excludes failed queries and paused mutations, and it redacts errors. The shouldDehydrateQuery option can include failed or pending queries, and the serializeData option can transform non-JSON data . The client must use a matching deserializeData if the data was transformed.
The hydration-timing problem.
The HydrationBoundary component hydrates the cache during the render. New queries—those not already in the client’s cache—are hydrated immediately, and existing queries are hydrated in an effect to avoid overwriting newer data during a React transition . The component is transparent: it renders its children as-is and does not add a wrapper to the DOM. It must be used inside a QueryClientProvider, because it needs access to the client from context .
a. The basic SSR pattern with prefetch, dehydrate, and hydrate
The server prefetches the data, dehydrates the cache, and passes the state to the client. The client wraps the component tree in a HydrationBoundary and the components use useQuery as they normally would.
// app/posts/page.tsx (Server Component)
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query'
import { Posts } from './posts'
async function getPosts() {
const res = await fetch('https://api.example.com/posts')
return res.json()
}
export default async function PostsPage() {
const queryClient = new QueryClient()
// Step 1: Prefetch on the server
await queryClient.prefetchQuery({
queryKey: ['posts'],
queryFn: getPosts,
})
// Step 2: Dehydrate the cache
// Step 3: Hydrate on the client with HydrationBoundary
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<Posts />
</HydrationBoundary>
)
}
The queryClient.prefetchQuery call fetches the data and stores it in the cache. The dehydrate(queryClient) call converts the cache into a serializable object. The HydrationBoundary component receives that object and hydrates the client’s cache . The Posts component is a Client Component that calls useQuery with the same key and function.
// app/posts/posts.tsx (Client Component)
'use client'
import { useQuery } from '@tanstack/react-query'
export function Posts() {
const { data, isPending } = useQuery({
queryKey: ['posts'],
queryFn: getPosts,
})
// data is available immediately after hydration
// isPending is false on the first render
if (isPending) return <p>Loading...</p>
return (
<ul>
{data.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}
The useQuery call in the client uses the same key and function as the server’s prefetchQuery. The hydration restores the data into the cache, and the useQuery call finds it immediately. The isPending flag is false on the first render, and the component renders the data without a loading state .
b. The QueryClient setup for SSR
The QueryClient setup differs between the server and the browser. On the server, a new client is created per request. In the browser, a singleton client is created once and reused, because creating a new client on every render would discard the cache.
// app/get-query-client.ts
import { QueryClient, isServer } from '@tanstack/react-query'
function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
// With SSR, we usually want to set some default staleTime
// above 0 to avoid refetching immediately on the client
staleTime: 60 * 1000,
},
},
})
}
let browserQueryClient: QueryClient | undefined = undefined
export function getQueryClient() {
if (isServer) {
// Server: always make a new query client
return makeQueryClient()
} else {
// Browser: make a new query client if we don't already have one
// This is very important, so we don't re-make a new client if React
// suspends during the initial render
if (!browserQueryClient) browserQueryClient = makeQueryClient()
return browserQueryClient
}
}
The isServer check ensures that a new client is created for every server request. The browserQueryClient variable is a singleton that survives React’s suspension during the initial render . The staleTime: 60 * 1000 sets the data as fresh for one minute after hydration, which prevents the immediate refetch that would otherwise occur because the server’s data is considered stale by default .
The QueryClientProvider wraps the application in the root layout. The HydrationBoundary is placed inside the provider, either at the page level or at the layout level.
// app/providers.tsx
'use client'
import { QueryClientProvider } from '@tanstack/react-query'
import { getQueryClient } from './get-query-client'
export function Providers({ children }: { children: React.ReactNode }) {
const queryClient = getQueryClient()
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
}
The Providers component is a Client Component, and it uses the getQueryClient function to obtain the client. The getQueryClient function returns a new client on the server and a singleton on the browser .
c. Dehydration options and the pending-query pattern
The dehydrate function accepts options that control which queries are included. The default is to include only successful queries. The shouldDehydrateQuery option can include pending or failed queries, which is useful for streaming or for prefetching data that is not awaited.
import { dehydrate, defaultShouldDehydrateQuery } from '@tanstack/react-query'
const dehydratedState = dehydrate(queryClient, {
shouldDehydrateQuery: (query) =>
defaultShouldDehydrateQuery(query) || query.state.status === 'pending',
shouldRedactErrors: () => false,
})
The shouldDehydrateQuery function includes the pending queries in the dehydrated state, which allows the server to start a fetch and pass the promise to the client without awaiting it . The client receives the promise and the useSuspenseQuery or useQuery call picks it up. The shouldRedactErrors function prevents the errors from being replaced with a generic error, which is useful for debugging .
The dehydrate function also accepts a serializeData option for non-JSON data. The client must use a matching deserializeData option in the hydrate call or the HydrationBoundary . The default serialization is JSON, and the default shouldDehydrateQuery includes only successful queries .
Complete Example Session
// ============================================
// PART 1: THE QUERY CLIENT FACTORY
// ============================================
// app/get-query-client.ts
import { QueryClient, isServer } from '@tanstack/react-query'
function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: { staleTime: 60 * 1000 },
},
})
}
let browserQueryClient: QueryClient | undefined
export function getQueryClient() {
if (isServer) return makeQueryClient()
if (!browserQueryClient) browserQueryClient = makeQueryClient()
return browserQueryClient
}
// ============================================
// PART 2: THE PROVIDER
// ============================================
// app/providers.tsx
'use client'
import { QueryClientProvider } from '@tanstack/react-query'
import { getQueryClient } from './get-query-client'
export function Providers({ children }: { children: React.ReactNode }) {
const queryClient = getQueryClient()
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
}
// ============================================
// PART 3: THE SERVER COMPONENT
// ============================================
// app/posts/page.tsx
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
import { getQueryClient } from '../get-query-client'
import { Posts } from './posts'
async function getPosts() {
const res = await fetch('https://api.example.com/posts')
return res.json()
}
export default async function PostsPage() {
const queryClient = getQueryClient()
await queryClient.prefetchQuery({
queryKey: ['posts'],
queryFn: getPosts,
})
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<Posts />
</HydrationBoundary>
)
}
// ============================================
// PART 4: THE CLIENT COMPONENT
// ============================================
// app/posts/posts.tsx
'use client'
import { useQuery } from '@tanstack/react-query'
export function Posts() {
const { data, isPending } = useQuery({
queryKey: ['posts'],
queryFn: getPosts,
})
if (isPending) return <p>Loading...</p>
return (
<ul>
{data.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}
The four parts demonstrate the complete SSR pattern: the client factory that separates server and browser, the provider, the server component that prefetches and dehydrates, and the client component that consumes the hydrated data. The critical separation is between Parts 1 and 3: the server uses a new client per request, and the browser uses a singleton.
Quick Reference
SSR Steps
| Step | Function | Location |
|---|---|---|
| Prefetch | queryClient.prefetchQuery | Server |
| Dehydrate | dehydrate(queryClient) | Server |
| Hydrate | <HydrationBoundary state={...}> | Client |
| Consume | useQuery with the same key | Client |
Key Functions
| Function | Purpose |
|---|---|
dehydrate | Convert cache to serializable state |
HydrationBoundary | Restore state into client cache |
hydrate | Imperative hydration (outside React) |
prefetchQuery | Fetch and store without a component |
Dehydrate Options
| Option | Default | Purpose |
|---|---|---|
shouldDehydrateQuery | Successful only | Include pending/failed |
shouldDehydrateMutation | Paused only | Include mutations |
serializeData | None | Transform non-JSON data |
shouldRedactErrors | True | Redact error messages |
QueryClient Rules
| Environment | Client Lifetime |
|---|---|
| Server | New per request |
| Browser | Singleton (module or useState) |
SSR staleTime | Greater than 0 |
Best Practices
✅ Do This:
// Create a new client per server request
if (isServer) return new QueryClient() # ✅
// Use a singleton on the browser
if (!browserQueryClient) browserQueryClient = makeQueryClient() # ✅
// Set staleTime above 0 for SSR
staleTime: 60 * 1000 # ✅
// Use the same key on server and client
queryKey: ['posts'] # ✅
// Wrap with HydrationBoundary inside the provider
<QueryClientProvider><HydrationBoundary state={...}>...</HydrationBoundary></QueryClientProvider> # ✅
// Prefetch in parallel with Promise.all
await Promise.all([prefetchQuery(q1), prefetchQuery(q2)]) # ✅
❌ Don’t Do This:
// Create the client at module scope on the server
const queryClient = new QueryClient() // Shared across requests # ❌
// Forget the HydrationBoundary
<Posts /> // The client fetches again immediately # ❌
// Use different keys on server and client
prefetchQuery({ queryKey: ['posts'] })
useQuery({ queryKey: ['post'] }) // Different cache entry # ❌
// Leave staleTime at 0
// The client refetches on mount # ❌
// Use useState for the browser client when a Suspense boundary is above
// React throws away the client on initial suspend # ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Data leaks between requests | Module-scope client on server | Create per request |
| Immediate refetch after hydration | staleTime: 0 | Set staleTime above 0 |
| Loading spinner on first render | No HydrationBoundary | Wrap the tree |
| Different data on server and client | Different query keys | Use the same key |
| Hydration warning | Serialized data is not JSON | Use serializeData |
| Client lost on suspension | useState with a Suspense boundary above | Use a module singleton |
Real-World Examples
1. Basic prefetch and dehydrate
await queryClient.prefetchQuery({ queryKey: ['posts'], queryFn: getPosts })
<HydrationBoundary state={dehydrate(queryClient)}><Posts /></HydrationBoundary>
2. QueryClient factory
if (isServer) return new QueryClient()
if (!browserClient) browserClient = new QueryClient()
return browserClient
3. Provider
<QueryClientProvider client={getQueryClient()}>{children}</QueryClientProvider>
4. Same key on both sides
queryKey: ['posts'], queryFn: getPosts
5. Parallel prefetch
await Promise.all([queryClient.prefetchQuery(q1), queryClient.prefetchQuery(q2)])
6. Include pending queries
dehydrate(queryClient, { shouldDehydrateQuery: (q) => defaultShouldDehydrateQuery(q) || q.state.status === 'pending' })
7. Custom serialization
dehydrate(queryClient, { serializeData: (data) => ({ ...data, date: data.date.toISOString() }) })
8. Imperative hydration
hydrate(queryClient, dehydratedState)
9. Hydration inside a page
<HydrationBoundary state={dehydrate(queryClient)}><Posts /></HydrationBoundary>
10. Check hydration status
const isHydrated = useIsFetching() === 0
Visual
The Three-Step SSR Flow
┌──────────────────────────────────────────────────────────────┐
│ SERVER → DEHYDRATE → CLIENT → HYDRATE → RENDER │
│ │
│ Server Component │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ queryClient.prefetchQuery({ queryKey: ['posts'] }) │ │
│ │ → cache: { posts: [...] } │ │
│ └──────────────────────────┬─────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ dehydrate(queryClient) │ │
│ │ → { queries: [{ queryKey, state: { data } }] } │ │
│ └──────────────────────────┬─────────────────────────────┘ │
│ │ │
│ │ embedded in HTML │
│ ▼ │
│ Client │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ <HydrationBoundary state={dehydratedState}> │ │
│ │ → client cache: { posts: [...] } │ │
│ │ → useQuery finds the data immediately │ │
│ └────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
QueryClient Lifetime
┌──────────────────────────────────────────────────────────────┐
│ SERVER: NEW PER REQUEST; BROWSER: SINGLETON │
│ │
│ Server │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Request 1 → new QueryClient → dehydrate → HTML │ │
│ │ Request 2 → new QueryClient → dehydrate → HTML │ │
│ │ Request 3 → new QueryClient → dehydrate → HTML │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ Browser │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Page load → one QueryClient → hydrate → reuse forever │ │
│ └────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
HydrationBoundary Timing
┌──────────────────────────────────────────────────────────────┐
│ NEW QUERIES HYDRATE DURING RENDER; EXISTING IN AN EFFECT │
│ │
│ New query (not in client cache): │
│ └── hydrated immediately during render │
│ │
│ Existing query (already in client cache): │
│ ├── hydrated in an effect │
│ ├── to avoid overwriting newer data during a transition │
│ └── only if the dehydrated data is newer │
└──────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Prefetch | queryClient.prefetchQuery on the server |
| Dehydrate | dehydrate(queryClient) |
| Hydrate | <HydrationBoundary state={...}> |
| Server client | New per request |
| Browser client | Singleton |
SSR staleTime | Greater than 0 |
| Same key | Server and client must match |
| Default dehydration | Successful queries only |
| Pending queries | shouldDehydrateQuery |
| Imperative hydration | hydrate(queryClient, state) |
Key takeaways:
- SSR with TanStack Query is prefetch, dehydrate, and hydrate. The server fetches the data, serializes the cache, and sends it to the client. The client restores the cache before the components render, and the
useQuerycalls find the data immediately . - The server client must be new per request. A module-scope client is shared across requests, and one user’s data leaks into another user’s HTML. The
isServercheck in thegetQueryClientfunction creates a new client on the server and a singleton on the browser . staleTimemust be greater than zero. The defaultstaleTimeof zero makes the hydrated data stale immediately, and the client refetches on mount. AstaleTimeof 60 seconds or more keeps the data fresh and prevents the immediate refetch .- The same query key must be used on the server and the client. If the keys differ, the hydration populates a different cache entry, and the client’s
useQuerydoes not find the data. The keys are the connection between the server’s prefetch and the client’s read . HydrationBoundarymust be insideQueryClientProvider. The component uses the client from context to hydrate the state. It is transparent—it renders its children as-is and does not add a wrapper to the DOM .- The
dehydrateoptions control what is serialized. The default includes only successful queries and redacts errors. TheshouldDehydrateQueryoption can include pending or failed queries, and theserializeDataoption can transform non-JSON data .
Remember: SSR is the mechanism that makes the first render instant. The server prefetches the data, dehydrates the cache, and embeds the state in the HTML. The client hydrates the cache with the HydrationBoundary and renders the data without a loading spinner. The server client is new per request, the browser client is a singleton, and the staleTime is above zero. The query keys match on both sides, and the provider wraps the boundary. The result is a page that arrives fully rendered, with the data already in the cache, and a client that does not fetch again until the data becomes stale.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!