Capítulo 36 de 80

Chapter 36: Server Rendering & Hydration

Core Idea

Server rendering turns the client's markup → JS → query waterfall into markup-with-data → JS by prefetching on the server, dehydrating that state into serializable JSON embedded in the page, and hydrating it back into a fresh client-side QueryClient — the full dehydrate/HydrationBoundary pipeline, not the simpler-but-flawed initialData shortcut.

Key Concepts

  • Per-request QueryClient: always construct the QueryClient inside the app (in React state or a ref), never at module scope — a module-scoped client is shared across every request/user on the server, leaking data between users.
  • Quick-but-flawed path: initialData: passing server-fetched data as initialData to useQuery works with zero extra APIs, but has real drawbacks — it must be threaded down to wherever useQuery is actually called, it silently never overwrites data already in the cache (even if the new value is fresher), and there's no real dataUpdatedAt since it's stamped at page-load time rather than server-fetch time.
  • Full pipeline: in a loader, prefetch with await queryClient.query(...) (parallelize with Promise.all where possible — it's fine to leave some queries unprefetched, they'll just fetch client-side after hydration), return dehydrate(queryClient), and wrap the rendered tree in <HydrationBoundary state={dehydratedState}>. Three separate QueryClient instances are involved end to end: the loader's preload client, the server-render client, and the client-side client — the dehydrated payload seeds the latter two identically so they render the same markup.
  • Dependent queries in a loader: since loaders are plain async code, a dependent chain (fetch user, then conditionally fetch that user's projects) just becomes sequential awaits in the loader before the final dehydrate(queryClient) call.
  • Error handling default: dehydrate() only includes successful queries by default; errors from void queryClient.query(...).catch(noop) are swallowed so the client retries them normally, and the server-rendered output shows a loading state for that piece instead of failing the whole page. For genuinely critical data, await without swallowing the error and handle it explicitly (404/500) instead.
  • Serialization constraints: only JSON-safe values survive the default dehydration/serialization round-trip — no undefined, Error, Date, Map, Set, BigInt, Infinity/NaN/-0, or regexes in query data, unless a richer serializer (e.g. superjson) is used. In a custom SSR setup, never JSON.stringify the dehydrated state directly into markup — that's an XSS vector; use an escaping-safe serializer (Serialize JavaScript, devalue) instead.
  • Waterfall improvement, with a catch: server rendering flattens the deep code-split waterfall on a direct/initial page load (markup ships with content and data already embedded, so JS chunks for nested components load in parallel instead of gated behind fetches). Client-side navigation to that same route afterward doesn't get this benefit automatically — frameworks that combine router-level prefetching with SSR patterns can still load code and data in parallel on navigation, just not fully flattened to a single round trip (that needs Server Components).
  • Staleness is server-clock-based: dataUpdatedAt reflects the server's fetch time (in UTC, so timezones don't matter) — since staleTime defaults to 0, a page will double-fetch on load unless a higher staleTime is set. This pairs well with CDN-cached markup: cache the page for a long time, but set query staleTime shorter so the background refetch keeps data current without re-rendering server-side.
  • Server memory caveat: gcTime defaults to Infinity on the server and is auto-cleared once a request completes — if you override it to a finite value, you own cleanup (e.g. queryClient.clear() after dehydration). Never set gcTime: 0 server-side — garbage collection can race the hydration boundary reading the data, causing a hydration error; use something like 2 * 1000 if a short gcTime is genuinely needed.

Code Examples

// Loader: prefetch, then dehydrate
export async function getStaticProps() {
  const queryClient = new QueryClient()
  await queryClient.query({ queryKey: ['posts'], queryFn: getPosts }).catch(noop)
  return { props: { dehydratedState: dehydrate(queryClient) } }
}

// Route: hydrate the prefetched state back into the client cache
export default function PostsRoute({ dehydratedState }) {
  return (
    <HydrationBoundary state={dehydratedState}>
      <Posts />
    </HydrationBoundary>
  )
}
  • What it demonstrates: the minimal loader → dehydrate → HydrationBoundary round trip that avoids initialData's drawbacks.

Key Takeaways

  1. Reach for the full dehydrate/HydrationBoundary pipeline over initialData for anything beyond a quick prototype — initialData silently never overwrites fresher cache data, which breaks repeated navigation to the same page.
  2. Never construct QueryClient at module scope in an SSR app — it must be created per-request, inside the component tree.
  3. Set a non-zero staleTime for SSR'd queries deliberately — the staleTime: 0 default causes an immediate client-side refetch on every page load, which usually isn't what you want when the markup already has fresh data.

Connects To

  • Advanced Server Rendering: streaming, Server Components, and the Next.js App Router extension of this chapter's patterns.
  • Prefetching & Router Integration: the client-side prefetching techniques this chapter's loaders apply server-side.
  • QueryClient: gcTime/clear(), central to the server-memory caveat here.