Wiring TanStack Query without cache leaks
Wire TanStack Query into the Next.js App Router with a per-request client and SSR hydration, so the first paint is server-rendered and no tenant's cache leaks into another's.
The CommentThread from last lesson works, but on first load it flashes a skeleton while a cold request resolves, because nothing has filled its cache yet.
It also has no provider, so in a real app it throws before rendering anything.
This lesson closes both gaps: it wires TanStack Query into the App Router so the first paint is server-rendered, and so the server-side client never leaks one tenant’s cache into another tenant’s render.
There are four parts: a provider, a per-request client, SSR-hydrated initial data, and a second invalidation surface to keep in sync. The rest depends on the per-request client, so we build toward it.
Install and mount the one provider
Section titled “Install and mount the one provider”Install both packages together, since you’ll want the devtools on hand:
pnpm add @tanstack/react-query @tanstack/react-query-devtoolsEvery useQuery and useMutation reads from one shared cache, and that cache reaches components through React context.
Context needs a provider above its consumers, so start by wrapping the app in a QueryClientProvider .
Because context is client-only, that provider has to live in a Client Component:
'use client';
import { QueryClientProvider } from '@tanstack/react-query';import { getQueryClient } from '@/lib/query-client';
export const Providers = ({ children }: { children: React.ReactNode }) => { const queryClient = getQueryClient();
return ( <QueryClientProvider client={queryClient}> {children} </QueryClientProvider> );};The 'use client' directive. Leave it out and QueryClientProvider, which is built on React context, throws createContext is not a function at render, since context exists only in Client Components.
'use client';
import { QueryClientProvider } from '@tanstack/react-query';import { getQueryClient } from '@/lib/query-client';
export const Providers = ({ children }: { children: React.ReactNode }) => { const queryClient = getQueryClient();
return ( <QueryClientProvider client={queryClient}> {children} </QueryClientProvider> );};getQueryClient() returns the client, fresh on the server and a singleton in the browser. How it does that is the next section; for now, read it as “the right client for wherever this is running.”
'use client';
import { QueryClientProvider } from '@tanstack/react-query';import { getQueryClient } from '@/lib/query-client';
export const Providers = ({ children }: { children: React.ReactNode }) => { const queryClient = getQueryClient();
return ( <QueryClientProvider client={queryClient}> {children} </QueryClientProvider> );};<QueryClientProvider client={...}> wraps children, so everything inside can reach the cache. This is the one provider for the whole app.
Then mount <Providers> once, in the root layout, so it sits above every route:
import { Providers } from './_components/providers';
export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html lang="en"> <body> <Providers>{children}</Providers> </body> </html> );}That’s the whole provider: one Client Component, mounted once. Next, the line we passed over, getQueryClient().
The per-request client and the leak it prevents
Section titled “The per-request client and the leak it prevents”Here a setup detail that looks harmless on a toy app becomes a data leak on a real one.
Why a module-scoped client leaks across requests
Section titled “Why a module-scoped client leaks across requests”The obvious way to make a client is once, at the top of a module:
export const queryClient = new QueryClient();On a single-user app on your laptop, that is fine. On a multi-tenant SaaS, it is a data-isolation bug, because of where module scope lives.
A module’s top-level code runs once per process, not once per request.
One server process handles request after request, tenant A then tenant B, all sharing the same loaded modules and so the same single queryClient.
When tenant A’s page prefetches their comments into it, those rows stay in the cache, and tenant B then renders against the same client and can read them.
This is the cache-layer version of forgetting the org filter on a database query: no individual query looks wrong, yet one tenant sees another’s data. The per-request rule is not an optional performance tweak; it is the line between a working library and a leak.
A fresh client per request, a singleton in the browser
Section titled “A fresh client per request, a singleton in the browser”The two runtimes need different things.
The browser has one user per session, so a module singleton is correct: one cache that persists across navigation, so returning to a screen reads from cache instead of refetching.
The server has a different user on every request, so it needs a fresh client per request, with no request’s cache reaching another’s render.
One file handles both by branching on where it runs.
The server half is the same React cache() you used to dedupe reads, applied to the client factory: a layout and a page that both prefetch into “the” client get the same client within one request, and a different one on the next.
import { defaultShouldDehydrateQuery, isServer, QueryClient,} from '@tanstack/react-query';import { cache } from 'react';
function makeQueryClient() { return new QueryClient({ defaultOptions: { queries: { staleTime: 60_000, gcTime: 5 * 60_000, refetchOnWindowFocus: false, }, dehydrate: { shouldDehydrateQuery: (query) => defaultShouldDehydrateQuery(query) || query.state.status === 'pending', }, }, });}
const getServerQueryClient = cache(makeQueryClient);
let browserQueryClient: QueryClient | undefined;
export function getQueryClient() { if (isServer) return getServerQueryClient(); return (browserQueryClient ??= makeQueryClient());}makeQueryClient is the single factory both branches call, so server and browser clients are configured identically and cannot drift.
import { defaultShouldDehydrateQuery, isServer, QueryClient,} from '@tanstack/react-query';import { cache } from 'react';
function makeQueryClient() { return new QueryClient({ defaultOptions: { queries: { staleTime: 60_000, gcTime: 5 * 60_000, refetchOnWindowFocus: false, }, dehydrate: { shouldDehydrateQuery: (query) => defaultShouldDehydrateQuery(query) || query.state.status === 'pending', }, }, });}
const getServerQueryClient = cache(makeQueryClient);
let browserQueryClient: QueryClient | undefined;
export function getQueryClient() { if (isServer) return getServerQueryClient(); return (browserQueryClient ??= makeQueryClient());}cache(makeQueryClient) wraps the factory in React’s request-scoped memoization: within one request every call returns the same client, and the next request gets a fresh one. Same cache() you used to dedupe reads, memoizing a client instead of a row.
import { defaultShouldDehydrateQuery, isServer, QueryClient,} from '@tanstack/react-query';import { cache } from 'react';
function makeQueryClient() { return new QueryClient({ defaultOptions: { queries: { staleTime: 60_000, gcTime: 5 * 60_000, refetchOnWindowFocus: false, }, dehydrate: { shouldDehydrateQuery: (query) => defaultShouldDehydrateQuery(query) || query.state.status === 'pending', }, }, });}
const getServerQueryClient = cache(makeQueryClient);
let browserQueryClient: QueryClient | undefined;
export function getQueryClient() { if (isServer) return getServerQueryClient(); return (browserQueryClient ??= makeQueryClient());}let browserQueryClient plus the ??= on the last line is a lazy module singleton: the first call in the browser creates the client, every call after returns it, so it persists across navigations for the session.
import { defaultShouldDehydrateQuery, isServer, QueryClient,} from '@tanstack/react-query';import { cache } from 'react';
function makeQueryClient() { return new QueryClient({ defaultOptions: { queries: { staleTime: 60_000, gcTime: 5 * 60_000, refetchOnWindowFocus: false, }, dehydrate: { shouldDehydrateQuery: (query) => defaultShouldDehydrateQuery(query) || query.state.status === 'pending', }, }, });}
const getServerQueryClient = cache(makeQueryClient);
let browserQueryClient: QueryClient | undefined;
export function getQueryClient() { if (isServer) return getServerQueryClient(); return (browserQueryClient ??= makeQueryClient());}The isServer branch. isServer is TanStack Query’s exported boolean, true during a server render and false in the browser, equivalent to typeof window === 'undefined' but clearer.
import { defaultShouldDehydrateQuery, isServer, QueryClient,} from '@tanstack/react-query';import { cache } from 'react';
function makeQueryClient() { return new QueryClient({ defaultOptions: { queries: { staleTime: 60_000, gcTime: 5 * 60_000, refetchOnWindowFocus: false, }, dehydrate: { shouldDehydrateQuery: (query) => defaultShouldDehydrateQuery(query) || query.state.status === 'pending', }, }, });}
const getServerQueryClient = cache(makeQueryClient);
let browserQueryClient: QueryClient | undefined;
export function getQueryClient() { if (isServer) return getServerQueryClient(); return (browserQueryClient ??= makeQueryClient());}shouldDehydrateQuery, extended to include 'pending'. By default the library serializes only settled queries when shipping the cache to the browser; adding pending serializes in-flight ones too, which is what makes streaming work. The hydration section explains why.
The trap and the fix, side by side:
export const queryClient = new QueryClient();One client for the whole process. Module scope runs once, not per request, so every tenant shares this client and tenant A’s prefetched rows sit in the cache when tenant B renders. On a multi-tenant deploy that is a data-isolation bug, not a style nit.
import { getQueryClient } from '@/lib/query-client';
const queryClient = getQueryClient();Fresh on the server, singleton in the browser. getQueryClient returns a request-scoped client on the server, so no request reads another’s cache, and the one persistent client in the browser. Same helper, two behaviors, no leak.
That isServer branch is the whole defense.
Everything downstream, the prefetch, the hydration, the devtools, assumes you obtain the client through getQueryClient(), never through a module-scoped new QueryClient().
Tuning the QueryClient defaults
Section titled “Tuning the QueryClient defaults”TanStack Query assumes always-live data: staleTime: 0 plus a refetch on every window focus.
That suits a trading dashboard, where a two-second-stale number is wrong, but not a typical web app, where an invoice or comment thread stays current for a minute and refetching on every glance is wasted work.
The factory sets three saner defaults once, and every query inherits them:
staleTime: 60_000treats data as fresh for a minute, so a remount or refocus within that window reads the cache instead of refetching.gcTime: 5 * 60_000keeps unused entries for five minutes, so navigating away and back is instant rather than a cold fetch.refetchOnWindowFocus: falsestops the screen reloading its data every time the user alt-tabs back, the default that most surprises newcomers.
Raise freshness per query only where live data matters, the way a comment thread polls with refetchInterval instead of dropping its staleTime to zero.
Prefetch on the server, hydrate on the client
Section titled “Prefetch on the server, hydrate on the client”This is where “no skeleton on first paint” comes from.
The page stays a Server Component.
Inside it you grab the per-request client with getQueryClient(), fill its cache with prefetchInfiniteQuery for the data the leaf will read, then wrap the Client Component in a HydrationBoundary .
dehydrate serializes the filled cache into the response, and <HydrationBoundary> rehydrates it into the browser’s client before the leaf’s useInfiniteQuery runs.
So the hook reads a warm cache on its first render: no loading state, no client round-trip, and the network read happened once, on the server.
Scrub through the handoff:
Server render. getQueryClient() returns the request-scoped instance, and prefetchInfiniteQuery runs fetchComments in-process: a direct database read, no HTTP. The cache fills with page 1.
Dehydrate. dehydrate(queryClient) snapshots that cache into a plain, serializable object, ready to ride along in the response.
The wire. The response, HTML plus the dehydrated cache, crosses to the browser. The only network trip for the first page.
Hydrate. <HydrationBoundary> injects the snapshot into the browser’s singleton client. The browser cache now holds page 1 without ever having fetched it.
Leaf renders warm. CommentThread’s useInfiniteQuery mounts, finds page 1 in the cache, and paints with no skeleton. Only later interactions (fetchNextPage, the poll) go over HTTP.
Here is the page that does it:
// app/(app)/invoices/[id]/page.tsximport { dehydrate, HydrationBoundary } from '@tanstack/react-query';import { getQueryClient } from '@/lib/query-client';import { fetchComments } from '@/lib/comments/fetch';import { commentKeys } from '@/lib/comments/keys';import { CommentThread } from './_components/comment-thread';
export default async function InvoicePage({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; const queryClient = getQueryClient();
await queryClient.prefetchInfiniteQuery({ queryKey: commentKeys.lists(id), queryFn: ({ pageParam }) => fetchComments(id, pageParam), initialPageParam: null, });
return ( <HydrationBoundary state={dehydrate(queryClient)}> <CommentThread invoiceId={id} /> </HydrationBoundary> );}getQueryClient() returns the per-request instance, the same helper the provider calls; here it lands on the cache()-wrapped server client.
// app/(app)/invoices/[id]/page.tsximport { dehydrate, HydrationBoundary } from '@tanstack/react-query';import { getQueryClient } from '@/lib/query-client';import { fetchComments } from '@/lib/comments/fetch';import { commentKeys } from '@/lib/comments/keys';import { CommentThread } from './_components/comment-thread';
export default async function InvoicePage({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; const queryClient = getQueryClient();
await queryClient.prefetchInfiniteQuery({ queryKey: commentKeys.lists(id), queryFn: ({ pageParam }) => fetchComments(id, pageParam), initialPageParam: null, });
return ( <HydrationBoundary state={dehydrate(queryClient)}> <CommentThread invoiceId={id} /> </HydrationBoundary> );}prefetchInfiniteQuery runs with the exact same queryKey and queryFn the leaf’s useInfiniteQuery uses. This key match is the whole contract: it lets the leaf find the prefetched data instead of refetching.
// app/(app)/invoices/[id]/page.tsximport { dehydrate, HydrationBoundary } from '@tanstack/react-query';import { getQueryClient } from '@/lib/query-client';import { fetchComments } from '@/lib/comments/fetch';import { commentKeys } from '@/lib/comments/keys';import { CommentThread } from './_components/comment-thread';
export default async function InvoicePage({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; const queryClient = getQueryClient();
await queryClient.prefetchInfiniteQuery({ queryKey: commentKeys.lists(id), queryFn: ({ pageParam }) => fetchComments(id, pageParam), initialPageParam: null, });
return ( <HydrationBoundary state={dehydrate(queryClient)}> <CommentThread invoiceId={id} /> </HydrationBoundary> );}await params. In Next.js 16 params is a Promise you await before reading, the same async-params shape from the App Router unit.
// app/(app)/invoices/[id]/page.tsximport { dehydrate, HydrationBoundary } from '@tanstack/react-query';import { getQueryClient } from '@/lib/query-client';import { fetchComments } from '@/lib/comments/fetch';import { commentKeys } from '@/lib/comments/keys';import { CommentThread } from './_components/comment-thread';
export default async function InvoicePage({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; const queryClient = getQueryClient();
await queryClient.prefetchInfiniteQuery({ queryKey: commentKeys.lists(id), queryFn: ({ pageParam }) => fetchComments(id, pageParam), initialPageParam: null, });
return ( <HydrationBoundary state={dehydrate(queryClient)}> <CommentThread invoiceId={id} /> </HydrationBoundary> );}dehydrate(queryClient) snapshots the filled cache, and <HydrationBoundary state={...}> carries it to the browser, wrapping only the part of the tree that needs the cache.
// app/(app)/invoices/[id]/page.tsximport { dehydrate, HydrationBoundary } from '@tanstack/react-query';import { getQueryClient } from '@/lib/query-client';import { fetchComments } from '@/lib/comments/fetch';import { commentKeys } from '@/lib/comments/keys';import { CommentThread } from './_components/comment-thread';
export default async function InvoicePage({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; const queryClient = getQueryClient();
await queryClient.prefetchInfiniteQuery({ queryKey: commentKeys.lists(id), queryFn: ({ pageParam }) => fetchComments(id, pageParam), initialPageParam: null, });
return ( <HydrationBoundary state={dehydrate(queryClient)}> <CommentThread invoiceId={id} /> </HydrationBoundary> );}<CommentThread> is the only 'use client' component here. The surrounding invoice surface stays a Server Component and ships zero query JavaScript.
// app/(app)/invoices/[id]/page.tsximport { dehydrate, HydrationBoundary } from '@tanstack/react-query';import { getQueryClient } from '@/lib/query-client';import { fetchComments } from '@/lib/comments/fetch';import { commentKeys } from '@/lib/comments/keys';import { CommentThread } from './_components/comment-thread';
export default async function InvoicePage({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; const queryClient = getQueryClient();
await queryClient.prefetchInfiniteQuery({ queryKey: commentKeys.lists(id), queryFn: ({ pageParam }) => fetchComments(id, pageParam), initialPageParam: null, });
return ( <HydrationBoundary state={dehydrate(queryClient)}> <CommentThread invoiceId={id} /> </HydrationBoundary> );}Note the missing 'use client' at the top: the page is a Server Component that prefetches, and only the leaf is interactive.
The key match in step 2 is worth pulling out, because getting it slightly wrong wastes the whole prefetch silently.
Prefetch with one key and read with a different one, even an array off by a single element, and the leaf finds nothing and cold-fetches on mount, with no error or warning.
That is why commentKeys exists: both the page and the leaf import the one key helper, so their keys are identical by construction, with no hand-typed array to drift.
One fetcher for server and browser
Section titled “One fetcher for server and browser”That prefetch hides a problem.
On the client, fetchComments calls fetch('/api/invoices/${id}/comments'), the route handler, which is correct: that route is the public contract, the URL any HTTP client hits.
But on the server, inside prefetchInfiniteQuery, fetching your own host opens an HTTP connection back to itself for data it could read straight from the database in the same process.
So fetchComments branches: run the Drizzle query directly on the server, fetch the route handler in the browser.
Both branches return the same Zod-validated shape, from one schema that the route handler’s response writer and this fetcher’s parser both import.
One function, two call sites, one contract, enforced by structure rather than convention:
import { commentPageSchema } from './schema';import { listInvoiceComments } from '@/db/queries/comments';
export async function fetchComments(invoiceId: string, cursor: string | null) { if (typeof window === 'undefined') { const page = await listInvoiceComments(invoiceId, cursor); return commentPageSchema.parse(page); }
const res = await fetch( `/api/invoices/${invoiceId}/comments?cursor=${cursor ?? ''}`, ); if (!res.ok) throw new Error('Failed to load comments'); return commentPageSchema.parse(await res.json());}One commentPageSchema, imported from the shared schema file. Both branches parse against it, so there is no second shape to fall out of sync.
import { commentPageSchema } from './schema';import { listInvoiceComments } from '@/db/queries/comments';
export async function fetchComments(invoiceId: string, cursor: string | null) { if (typeof window === 'undefined') { const page = await listInvoiceComments(invoiceId, cursor); return commentPageSchema.parse(page); }
const res = await fetch( `/api/invoices/${invoiceId}/comments?cursor=${cursor ?? ''}`, ); if (!res.ok) throw new Error('Failed to load comments'); return commentPageSchema.parse(await res.json());}The server branch: typeof window === 'undefined' is true during a server render, so prefetchInfiniteQuery lands here and reads from Drizzle in-process. No network, no HTTP loopback.
import { commentPageSchema } from './schema';import { listInvoiceComments } from '@/db/queries/comments';
export async function fetchComments(invoiceId: string, cursor: string | null) { if (typeof window === 'undefined') { const page = await listInvoiceComments(invoiceId, cursor); return commentPageSchema.parse(page); }
const res = await fetch( `/api/invoices/${invoiceId}/comments?cursor=${cursor ?? ''}`, ); if (!res.ok) throw new Error('Failed to load comments'); return commentPageSchema.parse(await res.json());}The browser branch: fetch the route handler, the only path the browser has to this data.
import { commentPageSchema } from './schema';import { listInvoiceComments } from '@/db/queries/comments';
export async function fetchComments(invoiceId: string, cursor: string | null) { if (typeof window === 'undefined') { const page = await listInvoiceComments(invoiceId, cursor); return commentPageSchema.parse(page); }
const res = await fetch( `/api/invoices/${invoiceId}/comments?cursor=${cursor ?? ''}`, ); if (!res.ok) throw new Error('Failed to load comments'); return commentPageSchema.parse(await res.json());}Both branches end in the same .parse. A mismatch on either path throws as a useInfiniteQuery error, so server and client reads cannot drift apart unnoticed.
The server branch carries one risk: the browser must never bundle the Drizzle code.
The typeof window guard makes the database call dead code in the client, but import { listInvoiceComments } pulls in server-only modules that would break the client build if reachable.
Because the route handler stays the browser’s only path to this data, those imports tree-shake out.
The rule: keep the direct database read behind the guard, and let the route handler be the client’s seam.
Mounting the devtools panel in development
Section titled “Mounting the devtools panel in development”The devtools you installed earlier are a floating panel listing every query by key: whether each is stale or fetching, when it last updated, and a button to invalidate or refetch it by hand. Make it part of your loop: while building the thread, open it, watch the comment query tick from fresh to stale at the 60-second mark, click Invalidate, and watch the refetch fire. The cache’s otherwise invisible behavior becomes something you can see.
Mount it inside <Providers>, gated on two conditions: render it only outside production, and load it with a dynamic import.
The NODE_ENV check keeps it from rendering for users; the dynamic import keeps the devtools code out of the production bundle.
A bare top-level import { ReactQueryDevtools } mounted unconditionally ships the panel, and its weight, to every user.
const ReactQueryDevtools = dynamic(() => import('@tanstack/react-query-devtools').then((m) => m.ReactQueryDevtools),);
// inside <QueryClientProvider>, after {children}:{process.env.NODE_ENV !== 'production' && <ReactQueryDevtools />}Two caches, two invalidations
Section titled “Two caches, two invalidations”This chapter’s first lesson warned that TanStack Query adds a second invalidation surface. Here is where that comes due.
Picture a Server Action that mutates shared data, say addCommentAction, posting a comment on the invoice.
The write side is the next lesson; treat the action as returning the canonical Result.
Internally it calls updateTag(invoiceTag(invoiceId)) so the Server Component parts of the page re-render: the invoice summary cards and the server-rendered comment count.
The writer is watching the page, so read-your-writes is the right shape: updateTag, not revalidateTag.
But updateTag does not touch the thread.
The useInfiniteQuery reading the comment list lives in the browser cache, which the server cannot reach.
Once the action resolves, the Client Component must invalidate that cache itself with queryClient.invalidateQueries({ queryKey: commentKeys.lists(id) }) to mark the thread stale and refetch.
updateTag(…) addCommentAction writes shared data queryClient.invalidateQueries(…) One mutation, two caches. updateTag refreshes the Server Component cache; queryClient.invalidateQueries refreshes the TanStack cache. A write to shared data must hit both, or one side goes stale.
A mutation that touches data both layers show must invalidate both. Forget one side and you ship the classic bug: the list paints fresh while the detail stays stale.
await addCommentAction(formData);queryClient.invalidateQueries({ queryKey: commentKeys.lists(id) });The action handles updateTag internally; the client adds invalidateQueries once the write lands.
The optimistic version, prepending the comment before the server confirms, is the next lesson.
The tenant boundary
Section titled “The tenant boundary”The server leak lived at the request boundary; the same leak lives here at the client’s tenant boundary. A cache that is right to keep across navigations is wrong to keep across a tenant switch.
On an org switch, the browser’s TanStack cache still holds the previous org’s data: its comments, lists, records. Leave it populated and org A’s data renders into org B’s session, the same isolation failure as the server leak, now client-side.
The fix: when activeOrganizationId changes, call queryClient.clear() before navigating into the new org.
const switchOrg = async (organizationId: string) => { await setActiveOrganization(organizationId); queryClient.clear(); router.refresh();};queryClient.clear() drops everything, the safe default where you would rather lose a cache hit than risk a leak.
When only some of your queries are org-scoped, removeQueries({ queryKey }) is more precise, clearing just the affected subtrees.
The whole wiring in five files
Section titled “The whole wiring in five files”Five files carry the wiring. The detail that matters most is marked: where the 'use client' boundary falls.
Directorysrc/
Directoryapp/
- layout.tsx wraps
{children}in<Providers> Directory_components/
- providers.tsx
'use client', mounts<QueryClientProvider>+ gated devtools
- providers.tsx
Directory(app)/
Directoryinvoices/
Directory
[id]/- page.tsx Server Component, prefetch +
<HydrationBoundary> Directory_components/
- comment-thread.tsx
'use client', the leaf, reads a warm cache
- comment-thread.tsx
- page.tsx Server Component, prefetch +
- layout.tsx wraps
Directorylib/
- query-client.ts
getQueryClient(), per-request on the server, singleton in the browser Directorycomments/
- fetch.ts
fetchCommentsdual-fetcher (server reads Drizzle, client fetches the route)
- fetch.ts
- query-client.ts
The two bold files are the only Client Components: the provider at the top, the leaf at the bottom. Everything between them, the page and its prefetch, renders on the server. Server Components own the first paint, TanStack owns the live cache, and the boundary between them is two lines.
The dehydrate-to-hydrate handoff is the trickiest step. Put it back in order:
Order the steps that take page 1 of the comment thread from a server-side database read to a warm first paint in the browser. Drag the items into the correct order, then press Check.
getQueryClient() to get the request-scoped client prefetchInfiniteQuery reads page 1 from Drizzle, in-process dehydrate(queryClient) serializes the filled cache into the response <HydrationBoundary> injects the dehydrated state into the browser’s client CommentThread’s useInfiniteQuery mounts and reads page 1 from the warm cache And one check on the mistake that turns a UI library into a data leak:
On your multi-tenant server you find one tenant’s comments rendering inside another tenant’s page. Which line is the cause?
export const queryClient = new QueryClient();const getServerQueryClient = cache(makeQueryClient);if (isServer) return getServerQueryClient();return (browserQueryClient ??= makeQueryClient());new QueryClient() at module scope is built once per process, not once per request. Every tenant’s render shares that one client, so rows one request prefetched still sit in the cache when the next tenant renders against it. The cache()-wrapped helper hands each request its own client; the isServer line routes the server to it; and the browser ??= singleton is correct because the browser serves one user for the whole session.The read side is wired: the thread paints from a warm cache, polls itself live, and never leaks one tenant’s cache into another’s render. Next comes the write side, the optimistic add and the invalidation that reconciles afterward.
External resources
Section titled “External resources”The canonical reference for the prefetch + HydrationBoundary setup this lesson builds, including streaming pending queries.
Maintainer TkDodo on every way to warm the cache before render — prefetch, initialData, and seeding from other entries.
The Server Component cache half of the two-invalidation story — the read-your-writes verb, and how it differs from revalidateTag.