Core Concepts
Dehydration
Dehydrate converts the QueryClient state to a serializable format on the server:import { dehydrate } from '@tanstack/react-query'
const dehydratedState = dehydrate(queryClient)
Hydration
Hydrate restores the dehydrated state on the client:import { hydrate } from '@tanstack/react-query'
hydrate(queryClient, dehydratedState)
Next.js App Router
Server Component (Prefetching)
// app/posts/page.tsx
import {
dehydrate,
HydrationBoundary,
QueryClient,
} from '@tanstack/react-query'
import { PostList } from './post-list'
async function getPosts() {
const res = await fetch('https://api.example.com/posts')
return res.json()
}
export default async function PostsPage() {
const queryClient = new QueryClient()
// Prefetch on the server
await queryClient.prefetchQuery({
queryKey: ['posts'],
queryFn: getPosts,
})
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<PostList />
</HydrationBoundary>
)
}
Client Component (Consuming)
// app/posts/post-list.tsx
'use client'
import { useQuery } from '@tanstack/react-query'
export function PostList() {
// This will use the prefetched data from the server
const { data } = useQuery({
queryKey: ['posts'],
queryFn: async () => {
const res = await fetch('https://api.example.com/posts')
return res.json()
},
})
return (
<ul>
{data.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}
Root Layout Setup
// app/layout.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
export default function RootLayout({ children }) {
const [queryClient] = useState(
() => 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,
},
},
})
)
return (
<html>
<body>
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
</body>
</html>
)
}
Set a
staleTime in SSR apps to prevent immediate refetching on the client. The prefetched data is usually fresh enough for initial render.Next.js Pages Router
// pages/posts.tsx
import { dehydrate, QueryClient, useQuery } from '@tanstack/react-query'
export async function getServerSideProps() {
const queryClient = new QueryClient()
await queryClient.prefetchQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
})
return {
props: {
dehydratedState: dehydrate(queryClient),
},
}
}
function Posts() {
const { data } = useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
})
return (
<ul>
{data.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}
export default Posts
_app.tsx Setup
// pages/_app.tsx
import { QueryClient, QueryClientProvider, Hydrate } from '@tanstack/react-query'
import { useState } from 'react'
export default function MyApp({ Component, pageProps }) {
const [queryClient] = useState(() => new QueryClient())
return (
<QueryClientProvider client={queryClient}>
<Hydrate state={pageProps.dehydratedState}>
<Component {...pageProps} />
</Hydrate>
</QueryClientProvider>
)
}
Remix
// routes/posts.tsx
import { json } from '@remix-run/node'
import { useLoaderData } from '@remix-run/react'
import { dehydrate, QueryClient, useQuery } from '@tanstack/react-query'
export async function loader() {
const queryClient = new QueryClient()
await queryClient.prefetchQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
})
return json({ dehydratedState: dehydrate(queryClient) })
}
export default function Posts() {
const { dehydratedState } = useLoaderData<typeof loader>()
const { data } = useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
})
return (
<ul>
{data.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}
Dehydrate Options
Customize what gets dehydrated:const dehydratedState = dehydrate(queryClient, {
// Only include successful queries (default behavior)
shouldDehydrateQuery: (query) => {
return query.state.status === 'success'
},
// Optionally include mutations
shouldDehydrateMutation: (mutation) => {
return mutation.state.isPaused
},
// Transform data before serialization
serializeData: (data) => {
// e.g., convert Dates to strings
return JSON.parse(JSON.stringify(data))
},
})
Custom Dehydration Rules
// Only dehydrate queries that are less than 30 seconds old
dehydrate(queryClient, {
shouldDehydrateQuery: (query) => {
const isSuccess = query.state.status === 'success'
const dataAge = Date.now() - query.state.dataUpdatedAt
const isFresh = dataAge < 30 * 1000
return isSuccess && isFresh
},
})
Hydrate Options
Customize hydration on the client:hydrate(queryClient, dehydratedState, {
defaultOptions: {
queries: {
staleTime: 60 * 1000,
},
},
// Transform data after deserialization
deserializeData: (data) => {
// e.g., convert strings back to Dates
return data
},
})
Streaming with Suspense
Next.js App Router supports streaming with React Server Components:import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query'
import { Suspense } from 'react'
export default async function Page() {
const queryClient = new QueryClient()
// Prefetch critical data
await queryClient.prefetchQuery({
queryKey: ['critical-data'],
queryFn: getCriticalData,
})
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<CriticalContent />
<Suspense fallback={<div>Loading...</div>}>
<DeferredContent />
</Suspense>
</HydrationBoundary>
)
}
Handling Errors in SSR
export async function getServerSideProps() {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
// Don't retry on the server
retry: false,
},
},
})
try {
await queryClient.prefetchQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
})
} catch (error) {
// Handle error - maybe return 404 or error page
return {
notFound: true,
}
}
return {
props: {
dehydratedState: dehydrate(queryClient),
},
}
}
Queries will not refetch on mount if they have fresh data from SSR. Set
staleTime: 0 if you need immediate refetching, but this defeats the purpose of SSR.Prefetching Multiple Queries
export async function getServerSideProps() {
const queryClient = new QueryClient()
// Prefetch in parallel
await Promise.all([
queryClient.prefetchQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
}),
queryClient.prefetchQuery({
queryKey: ['user'],
queryFn: fetchUser,
}),
queryClient.prefetchQuery({
queryKey: ['comments'],
queryFn: fetchComments,
}),
])
return {
props: {
dehydratedState: dehydrate(queryClient),
},
}
}
Prefetching Infinite Queries
import { dehydrate, QueryClient } from '@tanstack/react-query'
export async function getServerSideProps() {
const queryClient = new QueryClient()
await queryClient.prefetchInfiniteQuery({
queryKey: ['posts'],
queryFn: ({ pageParam = 0 }) => fetchPosts(pageParam),
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextCursor,
pages: 3, // Prefetch first 3 pages
})
return {
props: {
dehydratedState: dehydrate(queryClient),
},
}
}
Common Pitfalls
1. Not Setting staleTime
// ❌ Bad - Will refetch immediately on client
const queryClient = new QueryClient()
// ✅ Good - Respects SSR data for 60 seconds
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
},
},
})
2. Creating QueryClient Inside Component
// ❌ Bad - Creates new client on each render
function App() {
const queryClient = new QueryClient()
return <QueryClientProvider client={queryClient}>...</QueryClientProvider>
}
// ✅ Good - Stable client instance
function App() {
const [queryClient] = useState(() => new QueryClient())
return <QueryClientProvider client={queryClient}>...</QueryClientProvider>
}
3. Query Key Mismatch
// ❌ Bad - Keys don't match
await queryClient.prefetchQuery({ queryKey: ['posts'], queryFn: fetchPosts })
const { data } = useQuery({ queryKey: ['posts', 'all'], queryFn: fetchPosts })
// ✅ Good - Keys match exactly
await queryClient.prefetchQuery({ queryKey: ['posts'], queryFn: fetchPosts })
const { data } = useQuery({ queryKey: ['posts'], queryFn: fetchPosts })
Testing SSR
import { renderToString } from 'react-dom/server'
import { QueryClient, QueryClientProvider, dehydrate } from '@tanstack/react-query'
test('should render on server', async () => {
const queryClient = new QueryClient()
await queryClient.prefetchQuery({
queryKey: ['test'],
queryFn: () => Promise.resolve('data'),
})
const dehydratedState = dehydrate(queryClient)
const markup = renderToString(
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
)
expect(markup).toContain('data')
expect(dehydratedState.queries).toHaveLength(1)
})
Next Steps
- Prefetching - Advanced prefetching patterns