web/.agents/skills/tanstack-query-best-practices/rules/ssr-dehydration.md
For server-side rendering, prefetch queries on the server, dehydrate the cache to a serializable format, send it to the client, and hydrate on the client. This prevents content flash and duplicate requests.
// No SSR data passing - client refetches everything
// server-side
export async function getServerSideProps() {
const data = await fetchPosts()
return { props: { posts: data } } // Bypasses React Query cache
}
// client-side
function PostsPage({ posts }: { posts: Post[] }) {
// This doesn't benefit from the server fetch
const { data } = useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
// Will refetch on client, causing flash
})
return <PostList posts={data ?? posts} /> // Awkward fallback pattern
}
// app/posts/page.tsx
import {
dehydrate,
HydrationBoundary,
QueryClient,
} from '@tanstack/react-query'
import { postQueries } from '@/lib/queries'
export default async function PostsPage() {
const queryClient = new QueryClient()
await queryClient.prefetchQuery(postQueries.list())
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<PostList />
</HydrationBoundary>
)
}
// components/PostList.tsx
'use client'
import { useSuspenseQuery } from '@tanstack/react-query'
import { postQueries } from '@/lib/queries'
export function PostList() {
const { data: posts } = useSuspenseQuery(postQueries.list())
return (
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}
// routes/posts.tsx
import { createFileRoute } from '@tanstack/react-router'
import { postQueries } from '@/lib/queries'
export const Route = createFileRoute('/posts')({
loader: async ({ context: { queryClient } }) => {
// Prefetch in route loader
await queryClient.ensureQueryData(postQueries.list())
},
component: PostsPage,
})
function PostsPage() {
const { data: posts } = useSuspenseQuery(postQueries.list())
return <PostList posts={posts} />
}
// server.tsx
import { dehydrate, QueryClient } from '@tanstack/react-query'
import { renderToString } from 'react-dom/server'
export async function render(url: string) {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // Prevent immediate client refetch
},
},
})
// Prefetch required data
await queryClient.prefetchQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
})
const dehydratedState = dehydrate(queryClient)
const html = renderToString(
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
)
// Serialize safely - JSON.stringify is XSS vulnerable
const serializedState = serialize(dehydratedState)
return `
<html>
<body>
<div id="app">${html}</div>
<script>window.__DEHYDRATED_STATE__ = ${serializedState}</script>
</body>
</html>
`
}
// client.tsx
import { hydrate, QueryClient, QueryClientProvider } from '@tanstack/react-query'
const queryClient = new QueryClient()
hydrate(queryClient, window.__DEHYDRATED_STATE__)
hydrateRoot(
document.getElementById('app'),
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
)
staleTime > 0 on server to prevent immediate client refetchshouldDehydrateQuery to overrideHydrationBoundary can be nested for route-level prefetching