Definition
On a page that requires login, this is the pattern to use when you want the data to already be filled in the moment the screen appears. If you only request the data after the browser has opened, the loading spinner disappears a beat late; you eliminate that by having the server fetch the data in advance and ship it inside the HTML.
Spelled out: on the server you call the needed API in advance (prefetch), then put the result into the client data library (React Query · SWR, etc. — tools that manage caching · refetching of server data) cache and hand it over. Serializing the server cache into a string and embedding it in the HTML is called dehydrate, and the browser reviving it to fill the cache is called hydrate. Then the cache already has data from the first render, so no spinner shows. However, the query key that looks up the cache (e.g., ["items", "page=1&size=50"] — the array that distinguishes which request's result it is) must be exactly the same on server and client for a cache hit to occur.
Why it matters
On the home of an app that requires login, when the screen · key images are tied to API results, the browser alone produces a waterfall (requests chained one after another so the screen appears late) of "first check the session → then fetch the data." Here LCP (Largest Contentful Paint — when the largest element on screen is painted, the perceived-load-speed metric) gets held back by that data. Calling in parallel with Promise.all([fetchSession(), fetchList(query)]) in the server loader and handing the cache over with the HTML response reduces the loading delay. Conversely, if the query key mismatches, the prefetch is voided, and a late skeleton (a loading placeholder UI) and CLS (Cumulative Layout Shift — how much the layout shifts as content arrives late) can reappear.
How it works
- Server-only fetch — separate the API client into a
server-onlymodule. - Shared query keys — server and client import the identical
queryKeys.list(serializedParams). - Loader — redirect on auth failure; on success, prefetch + dehydrate.
- Client boundary — inject the cache via
HydrationBoundary, so a descendantuseQueryimmediately holdsdata. - Loading UI — skeleton only when
isPending && rows.length === 0(cls-skeleton-layout-reservation).
The client TableProvider's default page/pageSize/filter must be byte-for-byte identical to buildQueryString.
Practical application
- If the LCP image depends on an API URL: on the server, prefetch only auth + metadata (the first image URL); you don't need to put every byte into SSR (carousel-viewport-image-deferral).
- TTFB can rise with prefetch — be clear whether the goal is removing the client waterfall · reducing load delay.
- Streaming SSR is on the HTML flush timing axis. Deferring LCP-critical content to a later Suspense may raise only FCP while LCP stays put (streaming-ssr-metric-impact).
- E2E: keep login via storageState, then reproduce the trace.
Tradeoffs
| Choice | Benefit | Cost |
|---|---|---|
| Server parallel prefetch | initial data · LCP delay ↓ | TTFB · server work ↑ |
| Client-only fetch | server stays simple | waterfall · late paint |
| Aggressive prefetch all | high cache hit | needless data · serialization cost |
When not to use
- Public static pages (no auth · personalization) — when CDN/SSG alone suffices.
- When the query param is determined only on the client and the server can't know it (key consistency impossible).
Common mistakes
- Serialization mismatch such as server
page=1&size=50, clientpage_size=50. - Looking only at
isLoadingand ignoring the hydrateddata. - Not readjusting the skeleton design after adding prefetch → CLS regression.
Related concepts
- ssr-hydration-mismatch — server HTML vs first render
- largest-contentful-paint — auth-gated LCP
- cls-skeleton-layout-reservation — the prefetch + skeleton set
- carousel-viewport-image-deferral — prefetch only the meta, defer the bytes
- streaming-ssr-metric-impact — streaming flush vs LCP chunk placement