| name | ssr-hydration |
| description | Server rendering and hydration for TanStack Query v4 in RSC frameworks: request-scoped QueryClient with createGetQueryClient, server prefetch + dehydrate + hydrate with QueriesHydration, skipSsrOnError, timeout, per-boundary HTML streaming, QueryClientConsumer. Load when setting up QueryClient in Next.js App Router, prefetching queries in Server Components, or debugging cross-request cache leaks and hydration issues. |
| metadata | {"type":"sub-skill","library":"@suspensive/react-query-4","library_version":"3.21.2","framework":"react"} |
| requires | ["react-query"] |
| sources | ["toss/suspensive:docs/suspensive.org/src/content/en/docs/react-query/createGetQueryClient.mdx","toss/suspensive:docs/suspensive.org/src/content/en/docs/react-query/QueriesHydration.mdx"] |
This skill builds on react-query. Read ../SKILL.md first.
Two experimental APIs cover queries in SSR: createGetQueryClient manages the QueryClient lifecycle (new instance per server request, singleton in the browser), and QueriesHydration — an async Server Component — prefetches an array of queries on the server and hydrates them into client components, replacing manual QueryClient + prefetchQuery + dehydrate + Hydrate boilerplate (TSQ v4's Hydrate is what v5 renamed to HydrationBoundary; QueriesHydration wraps it internally). Both interfaces are experimental and may change.
On the server, getQueryClient forces the cache removal time (cacheTime in v4 — renamed gcTime in v5) to Infinity — automatic and non-overridable — so caches survive until the request completes and no setTimeout GC scheduling piles up under concurrent requests. The browser instance uses your configured value.
Setup
import { createGetQueryClient } from '@suspensive/react-query-4'
export const { getQueryClient } = createGetQueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
},
},
})
'use client'
import { QueryClientProvider } from '@tanstack/react-query'
import { getQueryClient } from './get-query-client'
import type { ReactNode } from 'react'
export const Providers = ({ children }: { children: ReactNode }) => {
const queryClient = getQueryClient()
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
}
import { Providers } from './providers'
import type { ReactNode } from 'react'
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>
<Providers>{children}</Providers>
</body>
</html>
)
}
Core Patterns
Per-boundary streaming with QueriesHydration
QueriesHydration is an async RSC — render it in a Server Component only. Give each section its own Suspense + QueriesHydration so each streams independently as its queries resolve.
import { Suspense } from '@suspensive/react'
import { QueriesHydration } from '@suspensive/react-query-4'
import { postsQueryOptions, userQueryOptions } from './queries'
import { PostList, UserProfile } from './_components'
export default function PostsPage({ userId }: { userId: number }) {
return (
<>
<Suspense fallback={<div>Loading user...</div>}>
<QueriesHydration queries={[userQueryOptions(userId)]}>
<UserProfile userId={userId} />
</QueriesHydration>
</Suspense>
<Suspense fallback={<div>Loading posts...</div>}>
<QueriesHydration queries={[postsQueryOptions(userId)]}>
<PostList userId={userId} />
</QueriesHydration>
</Suspense>
</>
)
}
'use client'
import { useSuspenseQuery } from '@tanstack/react-query'
import { userQueryOptions } from '../queries'
export const UserProfile = ({ userId }: { userId: number }) => {
const { data: user } = useSuspenseQuery(userQueryOptions(userId))
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
)
}
queries accepts a mix of queryOptions and infiniteQueryOptions results; every entry must have a queryKey. All queries in one array are fetched in parallel with Promise.all.
skipSsrOnError: let the browser take over when server fetch fails
Fetching happens at up to 3 stages: (1) RSC in QueriesHydration, (2) RCC on the server via useSuspenseQuery, (3) RCC in the browser. If stage 1 fails (for example cookies/auth unavailable on the server), stage 2 usually fails identically — but the browser can succeed.
true (default): on stage-1 error, skip SSR and fetch in the browser
{ fallback: ReactNode }: same, but render a custom fallback until the browser takes over
false: proceed to stage 2 without hydration (retry the fetch during server rendering of the client component)
import { Suspense } from '@suspensive/react'
import { QueriesHydration } from '@suspensive/react-query-4'
import { postsQueryOptions, userQueryOptions } from './queries'
import { PostList, UserProfile } from './_components'
export default function Page({ userId }: { userId: number }) {
return (
<>
<Suspense fallback={<div>Loading...</div>}>
<QueriesHydration queries={[userQueryOptions(userId)]}>
<UserProfile userId={userId} />
</QueriesHydration>
</Suspense>
<Suspense fallback={<div>Loading...</div>}>
<QueriesHydration
queries={[postsQueryOptions(userId)]}
skipSsrOnError={{ fallback: <div>Unable to fetch on server...</div> }}
>
<PostList userId={userId} />
</QueriesHydration>
</Suspense>
</>
)
}
timeout: bound server fetch time
<Suspense fallback={<div>Loading...</div>}>
<QueriesHydration queries={[userQueryOptions(userId)]} timeout={1000}>
<UserProfile userId={userId} />
</QueriesHydration>
</Suspense>
If the queries exceed timeout ms on the server, an error is thrown; combined with skipSsrOnError true/{ fallback }, the server gives up and the browser renders instead. No timeout applies when unset.
Dependent queries sharing one queryClient
import { QueryClient } from '@tanstack/react-query'
import { Suspense } from '@suspensive/react'
import { QueriesHydration } from '@suspensive/react-query-4'
import { productQueryOptions, relatedProductsQueryOptions } from './queries'
import { ProductInfo, RelatedProducts } from './_components'
export default async function ProductPage({ productId }: { productId: string }) {
const queryClient = new QueryClient()
const product = await queryClient.ensureQueryData(productQueryOptions(productId))
return (
<>
<Suspense fallback={<div>Loading product...</div>}>
<QueriesHydration queryClient={queryClient} queries={[productQueryOptions(productId)]}>
<ProductInfo productId={productId} />
</QueriesHydration>
</Suspense>
<Suspense fallback={<div>Loading related...</div>}>
<QueriesHydration queryClient={queryClient} queries={[relatedProductsQueryOptions(product.categoryId)]}>
<RelatedProducts categoryId={product.categoryId} />
</QueriesHydration>
</Suspense>
</>
)
}
QueryClientConsumer: read the QueryClient in JSX (client components)
QueryClientConsumer (experimental) exposes useQueryClient as a render prop for list rows and conditionals. On v4 it takes an optional context prop — TSQ v4's custom QueryClient context — where v5 takes a queryClient prop instead (the codemod npx @suspensive/codemods migrate-query-client-consumer-props . renames it when you upgrade).
'use client'
import { QueryClientConsumer } from '@suspensive/react-query-4'
export const RefreshButton = () => (
<QueryClientConsumer>
{(queryClient) => (
<button onClick={() => queryClient.invalidateQueries({ queryKey: ['posts'] })}>refresh posts</button>
)}
</QueryClientConsumer>
)
Common Mistakes
[CRITICAL] Module-level global QueryClient on the server
Wrong:
import { QueryClient } from '@tanstack/react-query'
export const queryClient = new QueryClient()
Correct:
import { createGetQueryClient } from '@suspensive/react-query-4'
export const { getQueryClient } = createGetQueryClient()
A module-level QueryClient is shared across server requests, leaking one user's cached data (auth tokens, personal data) into another user's response — createGetQueryClient returns a new instance per server request and a singleton only in the browser.
Source: docs/suspensive.org/src/content/en/docs/react-query/createGetQueryClient.mdx
[CRITICAL] Storing getQueryClient() result in a module global
Wrong:
import { getQueryClient } from './get-query-client'
export const queryClient = getQueryClient()
Correct:
import { getQueryClient } from './get-query-client'
const Component = () => {
const queryClient = getQueryClient()
return null
}
Exporting the call result freezes one instance at module-evaluation time and shares it across requests — exactly the leak the factory exists to prevent; call getQueryClient() at each use site.
Source: docs/suspensive.org/src/content/en/docs/react-query/createGetQueryClient.mdx
[HIGH] Passing config to getQueryClient instead of createGetQueryClient
Wrong:
const queryClient = getQueryClient({
defaultOptions: { queries: { staleTime: 60_000 } },
})
Correct:
export const { getQueryClient } = createGetQueryClient({
defaultOptions: { queries: { staleTime: 60_000 } },
})
getQueryClient takes no arguments — the browser singleton must be config-stable across calls — so defaults belong on createGetQueryClient(config).
Source: docs/suspensive.org/src/content/en/docs/react-query/createGetQueryClient.mdx
[HIGH] Using v5 gcTime instead of v4 cacheTime
Wrong:
export const { getQueryClient } = createGetQueryClient({
defaultOptions: { queries: { gcTime: 5 * 60 * 1000 } },
})
Correct:
export const { getQueryClient } = createGetQueryClient({
defaultOptions: { queries: { cacheTime: 5 * 60 * 1000 } },
})
TSQ v4 names the cache removal time cacheTime; gcTime is the v5 rename and does not exist in v4 option types. On the server, createGetQueryClient overrides it to Infinity regardless of what you pass.
Source: packages/react-query-4/src/createGetQueryClient.ts
[HIGH] Using QueriesHydration outside an async RSC
Wrong:
'use client'
import { QueriesHydration } from '@suspensive/react-query-4'
export const Posts = ({ userId }: { userId: number }) => (
<QueriesHydration queries={[postsQueryOptions(userId)]}>
<PostList userId={userId} />
</QueriesHydration>
)
Correct:
import { QueriesHydration } from '@suspensive/react-query-4'
export default function PostsPage({ userId }: { userId: number }) {
return (
<QueriesHydration queries={[postsQueryOptions(userId)]}>
<PostList userId={userId} />
</QueriesHydration>
)
}
QueriesHydration is an async Server Component; it only works in RSC frameworks (Next.js 13+ App Router) and cannot render in client components or the pages router.
Source: docs/suspensive.org/src/content/en/docs/react-query/QueriesHydration.mdx
[MEDIUM] Manual prefetch/dehydrate boilerplate in every RSC
Wrong:
const queryClient = new QueryClient()
await queryClient.prefetchQuery(postQueryOptions(id))
await queryClient.prefetchQuery(commentsQueryOptions(id))
return <Hydrate state={dehydrate(queryClient)}>{children}</Hydrate>
Correct:
<QueriesHydration queries={[postQueryOptions(id), commentsQueryOptions(id)]}>{children}</QueriesHydration>
QueriesHydration automates client creation, parallel prefetch, dehydrate, and hydration (v4's Hydrate internally) in one component, mixes regular and infinite queries, and adds skipSsrOnError/timeout for free.
Source: docs/suspensive.org/src/content/en/docs/react-query/QueriesHydration.mdx
[MEDIUM] Passing queryClient prop to QueryClientConsumer on v4
Wrong:
<QueryClientConsumer queryClient={queryClient}>
{(client) => <button onClick={() => client.invalidateQueries({ queryKey: ['posts'] })}>refresh</button>}
</QueryClientConsumer>
Correct:
<QueryClientConsumer context={queryClientContext}>
{(client) => <button onClick={() => client.invalidateQueries({ queryKey: ['posts'] })}>refresh</button>}
</QueryClientConsumer>
On @suspensive/react-query-4 the component mirrors TSQ v4's useQueryClient({ context }) and takes an optional context prop; the queryClient prop only exists on @suspensive/react-query-5. The codemod npx @suspensive/codemods migrate-query-client-consumer-props . renames context → queryClient when upgrading to v5.
Source: packages/react-query-4/src/QueryClientConsumer.tsx
Tensions
HIGH Tension: getting-started simplicity vs server security
A module-level new QueryClient() is the simplest setup and perfectly correct in a pure SPA — but copied into an SSR app it becomes a cross-request data leak. When code moves from CSR to Next.js App Router, converting the global client to createGetQueryClient is mandatory, not optional cleanup. Never silence this by "it works in dev" — the leak only manifests under concurrent production requests.
HIGH Tension: clientOnly simplicity vs SSR value
Skipping SSR with <Suspense clientOnly> (see @suspensive/react's skills/react/ssr-client-only skill) is the easiest fix for hydration problems, but it discards streaming SSR and server prefetch that QueriesHydration exists to provide. Conversely, do not build a QueriesHydration pipeline for a widget that should simply be client-only: a clientOnly boundary removes the need for QueriesHydration under it, because no server prefetch happens there.
<Suspense clientOnly fallback={<div>Loading user...</div>}>
<UserProfile userId={userId} />
</Suspense>
<Suspense fallback={<div>Loading posts...</div>}>
<QueriesHydration queries={[postsQueryOptions(userId)]}>
<PostList userId={userId} />
</QueriesHydration>
</Suspense>
See also: ../prefetching/SKILL.md for client-render prefetching, ../declarative-queries/SKILL.md for the client components consuming hydrated data, @suspensive/react skills/react/ssr-client-only for clientOnly and ClientOnly/useIsClient.