Back to Ragflow

ssr-dehydration: Use Dehydrate/Hydrate Pattern for SSR

web/.agents/skills/tanstack-query-best-practices/rules/ssr-dehydration.md

0.25.13.8 KB
Original Source

ssr-dehydration: Use Dehydrate/Hydrate Pattern for SSR

Priority: MEDIUM

Explanation

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.

Bad Example

tsx
// 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
}

Good Example: Next.js App Router

tsx
// 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>
  )
}

Good Example: TanStack Start/Router

tsx
// 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} />
}

Good Example: Manual SSR Setup

tsx
// 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>
)

Context

  • Create new QueryClient per request to prevent data sharing between users
  • Set staleTime > 0 on server to prevent immediate client refetch
  • Use a safe serializer (not JSON.stringify) to prevent XSS
  • Failed queries aren't dehydrated by default; use shouldDehydrateQuery to override
  • HydrationBoundary can be nested for route-level prefetching