Skip to content
Chapter 76Lesson 3

Wiring TanStack Query without leaking the cache across requests

Integrate 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 you wrote last lesson works, but the first time it loads it flashes a skeleton while a cold request resolves, because nothing has filled its cache yet. It also has no provider, so dropped into a real app it throws before it renders a single comment. This lesson closes both gaps, and it answers the question the call site couldn’t: how do you wire TanStack Query into the App Router so the first paint is server-rendered, and the server-side client never leaks one tenant’s cache into another tenant’s render? There are four moving parts: a provider, a per-request client, SSR-hydrated initial data, and a second invalidation surface to keep in sync. The per-request rule is the one everything else depends on, so we build toward it.

Install both packages together, because the devtools are part of your daily loop, not a tool you reach for only when something breaks:

Terminal window
pnpm add @tanstack/react-query @tanstack/react-query-devtools

Every useQuery and useMutation in your app reads from one shared cache, and that cache is exposed through React context. Context needs a provider somewhere above the components that consume it, so the first thing to set up is a QueryClientProvider wrapping the app.

Because context is a client-only feature, that provider has to live in a Client Component:

app/_components/providers.tsx
'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. Leaving it out is a common setup mistake: QueryClientProvider is built on React context, and context exists only in Client Components, so a server-side provider file throws createContext is not a function at render. The directive is what makes the rest of the file work.

app/_components/providers.tsx
'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() obtains the client. How it obtains one, fresh on the server and a singleton in the browser, is the entire point of the next section. For now, read it as “get me the right client for wherever this is running.”

app/_components/providers.tsx
'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. Everything rendered inside it can now reach the cache. This is the one provider for the whole app.

1 / 1

Then mount <Providers> once, in the root layout, so it sits above every route:

app/layout.tsx
import { Providers } from './_components/providers';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}

That is the whole provider story: one Client Component, mounted once. The interesting part is 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”

This is the heart of the lesson. It is the one place where a setup detail that looks harmless on a toy app turns into a data leak on a real one. Before walking through the fix, it helps to see the bug it prevents.

The obvious way to make a client is to write it once at the top of a module:

export const queryClient = new QueryClient();

On a single-user app running on your laptop, that line is fine and nothing ever goes wrong. On a multi-tenant SaaS deployed to a server, it is a data-isolation bug, and the reason comes down to where module scope lives.

A module’s top-level code runs once per process, not once per request. Your server process handles request after request, tenant A, then tenant B, then tenant C, all sharing the same loaded modules and therefore the same single queryClient. So when the page for tenant A prefetches their comments into that client, those rows sit in the cache. Then tenant B’s request comes in, renders against the same client, and can read tenant A’s rows out of it.

This is the same failure as forgetting the org filter in a database query, one tenant seeing another tenant’s data, except it happens at the cache layer, where it is easy to miss because no individual query looks wrong. That is why the per-request rule exists. It is not a performance tweak you can skip; it is the line between a working UI library and a leak.

The fix has two halves, because the two runtimes have genuinely different needs.

The browser has exactly one user for the whole session. There, a module singleton is not just acceptable, it is correct: you want one cache that persists as the user navigates between pages, so returning to a screen they already visited reads from cache instead of refetching.

The server has a different user on every request. There, you need a fresh client per request so one request’s cache can never reach another’s render.

One file handles both by branching on whether it is running on the server. The server half is something you have already built before, under a different name: it is the same request-scoped memoization you met for deduping reads, React’s cache() . Earlier you wrapped a database read in cache() so a layout and a page that both needed the same row triggered one query instead of two. Here you wrap the client factory in cache() for the same reason: a layout and a page that both prefetch into “the” client need to get the same client within one request, but a different one on the next request. You are not learning a new tool; you are pointing one you already trust at a new target.

src/lib/query-client.ts
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. The config lives here, in one place, so the server client and the browser client are configured identically, with no way for them to drift apart.

src/lib/query-client.ts
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 server request every call returns the same client; the next request gets a fresh one. This is the cache() you already used to dedupe reads, now memoizing a client instead of a row.

src/lib/query-client.ts
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, paired with the ??= on the last line, is a lazy module singleton. The first call in the browser creates the client; every call after returns that same one, so it persists across client navigations for the whole session.

src/lib/query-client.ts
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 own exported boolean: true during a server render, false in the browser. It is equivalent to writing typeof window === 'undefined' yourself, just clearer about intent.

src/lib/query-client.ts
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 shouldDehydrateQuery line, extended to include 'pending'. By default the library only serializes settled queries when it ships the cache to the browser; adding the pending status lets it serialize in-flight ones too, which is what makes streaming work. You will see exactly why in the hydration section two sections from now; hold the detail until then.

1 / 1

Put the trap and the fix side by side, since this contrast is the thing to carry out of the lesson:

export const queryClient = new QueryClient();

One client for the whole server process. Module scope runs once, not once per request, so every tenant’s render shares this single client, and tenant A’s prefetched rows are sitting in the cache when tenant B’s request renders against it. On a multi-tenant deploy this is a data-isolation bug, not a style nit.

That isServer branch is the whole defense. Everything else in this lesson, the prefetch, the hydration, the devtools, assumes you obtain the client through getQueryClient() and never through a module-scoped new QueryClient().

Two of the config values in that factory are worth defining, since their names suggest the wrong thing:

queries: {
staleTime: 60_000,
gcTime: 5 * 60_000,
refetchOnWindowFocus: false,
},
// ...
export function getQueryClient() {
if (isServer) return getServerQueryClient();
return (browserQueryClient ??= makeQueryClient());
}

The factory sets three options on the client, and each is worth a moment, because the library’s own defaults are tuned for a workload most SaaS screens are not.

Out of the box, TanStack Query assumes always-live data: staleTime: 0, plus an aggressive refetch on every window focus. That is the right call for a trading dashboard, where a number that is two seconds stale is a number that is wrong. It is the wrong call for a SaaS app, where most reads, an invoice, a customer record, a comment thread, stay current for a minute at a time, and refetching them on every glance is wasted work.

So you set saner defaults once, on the client, and every query in the app inherits them. Three values do the work:

  • staleTime: 60_000 treats data as fresh for a minute, so remounting a component or refocusing the tab within that window reads straight from the cache instead of firing a wave of refetches.
  • gcTime: 5 * 60_000 keeps unused entries around for five minutes before evicting them, so navigating away and coming back is instant rather than a cold fetch.
  • refetchOnWindowFocus: false means alt-tabbing back to the app does not trigger a wave of refetches. This is the most surprising default for newcomers: leave it on, and every time the user returns from their email the whole screen quietly reloads its data.

Set this baseline at the provider, and raise freshness per query only where live data genuinely matters, the way the comment thread reaches for refetchInterval to poll rather than dropping its staleTime to zero. This is the same “60 seconds is the SaaS default” you met at the call site last lesson; here it becomes the app-wide baseline, so it is no longer a line you have to remember to paste. When a single query does need different freshness, you override it at that one call site and leave a comment explaining why.

This section is where “no skeleton on first paint” actually comes from.

The shape, stated up front: 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 render a HydrationBoundary wrapping the Client Component. dehydrate serializes the freshly 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 very first render: no loading state, no client round-trip. The network read happened exactly once, on the server, in-process.

That handoff is the one genuinely non-obvious runtime sequence in the whole setup. The natural objection, “doesn’t the client just refetch anyway?”, is best answered by watching the cache fill on one side and get inherited on the other. Scrub through it:

Server one request
QueryClient request-scoped
comments · page 1 prefetched from Drizzle
dehydrated state plain, serializable
RSC payload HTML + dehydrated state
Browser one session
QueryClient singleton
comments · page 1 inherited — never fetched
<CommentThread /> useInfiniteQuery reads the warm cache

Server render. The Server Component calls getQueryClient() for the request-scoped instance, and prefetchInfiniteQuery runs fetchComments in-process: a direct database read, no HTTP. The server-side cache fills with page 1.

Server one request
QueryClient request-scoped
comments · page 1 prefetched from Drizzle
dehydrated state plain, serializable
RSC payload HTML + dehydrated state
Browser one session
QueryClient singleton
comments · page 1 inherited — never fetched
<CommentThread /> useInfiniteQuery reads the warm cache

Dehydrate. dehydrate(queryClient) snapshots that cache into a plain, serializable object, ready to ride along in the response.

Server one request
QueryClient request-scoped
comments · page 1 prefetched from Drizzle
dehydrated state plain, serializable
RSC payload HTML + dehydrated state
Browser one session
QueryClient singleton
comments · page 1 inherited — never fetched
<CommentThread /> useInfiniteQuery reads the warm cache

The wire. The response, HTML plus the dehydrated cache, crosses to the browser. This is the only network trip for the first page.

Server one request
QueryClient request-scoped
comments · page 1 prefetched from Drizzle
dehydrated state plain, serializable
RSC payload HTML + dehydrated state
Browser one session
QueryClient singleton
comments · page 1 inherited — never fetched
<CommentThread /> useInfiniteQuery reads the warm cache

Hydrate. <HydrationBoundary> injects the dehydrated snapshot into the browser’s singleton client. The browser cache now holds page 1 without ever having fetched it.

Server one request
QueryClient request-scoped
comments · page 1 prefetched from Drizzle
dehydrated state plain, serializable
RSC payload HTML + dehydrated state
Browser one session
QueryClient singleton
comments · page 1 inherited — never fetched
<CommentThread /> useInfiniteQuery reads the warm cache

Leaf renders warm. CommentThread’s useInfiniteQuery mounts, finds page 1 already in the cache, and paints immediately, with no isPending skeleton on first load. Only later interactions (fetchNextPage, the poll) go over HTTP.

The thing the diagram makes concrete is that the client does not re-fetch what was prefetched. The browser’s useInfiniteQuery looks in the cache, finds page 1 already there, put there by hydration rather than by a fetch, and renders it. The skeleton never flashes on the initial load, and there is no duplicate request for data the server already read.

Here is the page that does it:

// app/(app)/invoices/[id]/page.tsx
import { 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 server branch of the helper. It is the same function the provider calls; here it lands on the cache()-wrapped server client.

// app/(app)/invoices/[id]/page.tsx
import { 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 is what lets the leaf find the prefetched data instead of refetching. The next paragraph spells out what happens when it goes wrong.

// app/(app)/invoices/[id]/page.tsx
import { 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 you have used since the App Router unit. One line, nothing new.

// app/(app)/invoices/[id]/page.tsx
import { 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. The boundary wraps only the part of the tree that needs the cache.

// app/(app)/invoices/[id]/page.tsx
import { 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 on this page. The page itself ships zero query JavaScript for everything around the thread; the surrounding invoice surface stays a Server Component.

// app/(app)/invoices/[id]/page.tsx
import { 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>
);
}

Notice what is missing: there is no 'use client' at the top of this file. The page is a Server Component that prefetches; only the leaf is interactive.

1 / 1

The key match in step 2 is worth pulling out, because getting it slightly wrong wastes the whole prefetch and gives you no sign that it happened. If you prefetch with one key and the leaf reads with a different one, even an array that differs by a single element, the leaf finds nothing in the cache and cold-fetches on mount. No error, no warning; the prefetch just did nothing. This is exactly 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 on either side to drift. The discipline you set up last lesson pays off right here.

Look back at that prefetch and there is a gap in the story. On the client, fetchComments calls fetch('/api/invoices/${id}/comments'), your route handler. That is correct on the client: the route handler is the public contract, the same URL a future mobile app or third-party integration would hit. But on the server, inside prefetchInfiniteQuery, calling fetch() to your own host is wasteful. The server would open an HTTP connection back to itself to retrieve data it could read straight from the database in the same process.

The fix is one function with a branch. fetchComments runs the Drizzle query directly when it is on the server, and fetches the route handler when it is in the browser. Both branches return the same Zod-validated shape, the 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 a convention you have to remember:

src/lib/comments/fetch.ts
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 below parse against it, so both paths are typed by the exact same contract, with no second shape to fall out of sync.

src/lib/comments/fetch.ts
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.

src/lib/comments/fetch.ts
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: in the client, fetch the route handler. This is the same public endpoint any HTTP client uses, and the only path the browser has to this data.

src/lib/comments/fetch.ts
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 shape mismatch on either path throws, and that throw surfaces as a useInfiniteQuery error, so server and client reads cannot drift apart unnoticed.

1 / 1

One detail in that server branch is worth a sentence of care. The browser must never be able to bundle the Drizzle code: the typeof window guard makes the database call dead code in the client, but the import { listInvoiceComments } at the top, which pulls in server-only Drizzle modules, would break the client build if it were reachable. In practice the route handler stays the browser’s only path to this data, so the server-only imports tree-shake out. The full bundling rules are a topic of their own, but the rule of thumb is simple: keep the direct database read behind the guard, and let the route handler be the client’s seam.

The devtools you installed earlier are a floating panel that lists every query by key, shows whether each is stale or fetching and when it last updated, and gives you a button to invalidate or refetch any of them by hand. Treat it as part of your everyday loop, not a tool you open only in emergencies: 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. It makes the cache’s behavior, which is otherwise invisible, something you can see.

Mount it inside <Providers>, but gate it so it never reaches your users. You need two conditions together: render it only when not in production, and load it with a dynamic import so the devtools code tree-shakes out of the production bundle entirely. Gating the render alone still ships the bytes; the dynamic import is what keeps them out:

app/_components/providers.tsx
const ReactQueryDevtools = dynamic(() =>
import('@tanstack/react-query-devtools').then((m) => m.ReactQueryDevtools),
);
// inside <QueryClientProvider>, after {children}:
{process.env.NODE_ENV !== 'production' && <ReactQueryDevtools />}

A bare top-level import { ReactQueryDevtools } mounted unconditionally ships the panel, and its weight, straight to production. The gate on NODE_ENV keeps it from rendering for users; the dynamic import keeps it from being bundled at all. You want both.

This is the cost the chapter’s first lesson warned about when it noted that bringing TanStack Query in adds a second invalidation surface. This is where that bill comes due, and the right move is to make it visible rather than discover it as a bug.

The setup: a Server Action mutates shared data, say addCommentAction, posting a new comment on the invoice. The full write side is the next lesson’s job; here, treat the action as a known thing that returns the canonical Result. That action does its own updateTag(invoiceTag(invoiceId)) so the Server Component parts of the page re-render: the invoice summary cards, and the server-rendered comment count in the surrounding layout. The writer just posted and is watching the page, so read-your-writes is the right shape: updateTag, not revalidateTag. That handles one cache.

But it does not touch the thread. The useInfiniteQuery reading the comment list lives in the browser cache, a completely separate cache that the server’s updateTag cannot reach. So after the action resolves, the Client Component has to invalidate that cache itself, with queryClient.invalidateQueries({ queryKey: commentKeys.lists(id) }), to mark the thread stale and refetch it. Both calls fire, because both layers are holding the same data, and each layer has its own way to be told it is out of date.

These are two distinct caches, not one, and the picture is worth seeing:

Server Component cache summary cards, server-rendered counts
updateTag(…)
Mutation addCommentAction writes shared data
queryClient.invalidateQueries(…)
TanStack client cache the useInfiniteQuery comment thread

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.

Here is the rule to carry away:

updateTag (and its eventual-consistency sibling revalidateTag(tag, 'max'), the one you reach for from a webhook or background job) speaks to the Server Component cache. queryClient.invalidateQueries speaks to the TanStack cache. 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, or the other way around, depending on which cache you forgot.

The client-side half hangs off the action resolving:

await addCommentAction(formData);
queryClient.invalidateQueries({ queryKey: commentKeys.lists(id) });

The action handles its own updateTag internally; the client adds the invalidateQueries on top, once the write has landed. The optimistic version of this, prepending the comment before the server confirms and then reconciling, is the next lesson’s whole subject. Here it is enough to see that the client-side invalidation hangs off the action resolving.

The lesson opened with a leak at the server’s request boundary. It closes with the same leak at the client’s tenant boundary, because the cache that is correct to keep across navigations is exactly wrong to keep across a tenant switch.

When the user switches their active organization, the browser’s TanStack cache is still full of the previous org’s data: its comments, its lists, its records. The cache carries no org filter of its own; it holds whatever the last org loaded. Leave it populated across the switch and org A’s data renders into org B’s session. This is the same data-isolation failure as the server-side leak, now on the client, now triggered by a user action instead of a new request.

The fix is to throw the cache away on the switch. 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, which is the safe default at a tenant boundary, where you would rather lose a cache hit than risk a leak. When only some of your queries are org-scoped, removeQueries({ queryKey }) is the more precise option, clearing just the affected subtrees. At a hard tenant boundary, reach for clear() and refetch from scratch in the new org’s context, since the cache cannot survive a tenant change.

This bookends the leak theme. The server leak lives at the request boundary and is solved by a per-request client; the client leak lives at the tenant boundary and is solved by clearing on switch. The principle is the same, the cache must be scoped to the tenant, enforced at two different points.

Five files, and the whole wiring runs through them. Here is the map, with the detail that matters most 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
      • Directory(app)/
        • Directoryinvoices/
          • Directory[id]/
            • page.tsx Server Component, prefetch + <HydrationBoundary>
            • Directory_components/
              • comment-thread.tsx 'use client', the leaf, now reading a warm cache
    • Directorylib/
      • query-client.ts getQueryClient(), per-request on the server, singleton in the browser
      • Directorycomments/
        • fetch.ts the dual-fetcher fetchComments (server reads Drizzle, client fetches the route)

The page in the middle of that tree is a Server Component, with no 'use client'. The two bold files are the only Client Components: the provider at the top, the leaf at the bottom. Everything between them, the page, the prefetch, and the surrounding invoice surface, renders on the server. That is the integration shape promised two lessons ago, now made concrete: Server Components own the first paint, TanStack owns the live cache, and the boundary between them is two lines.

The one sequence worth being able to reconstruct on your own is the dehydrate-to-hydrate handoff, since it is the trickiest part of the model. 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.

The Server Component calls getQueryClient() to get the request-scoped client
prefetchInfiniteQuery reads page 1 from Drizzle, in-process
dehydrate(queryClient) serializes the filled cache into the response
The response crosses the wire to the browser
<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 central idea, since it is the one 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());

The read side is now wired and server-fast: the thread paints from a warm cache on first load, polls itself live, and never leaks one tenant’s cache into another’s render. What is still missing is the write side: the optimistic add that prepends a comment before the server confirms it, and the two-system invalidation that reconciles afterward. The next lesson frames that write side and walks the whole comment-thread screen against the chapter’s four triggers; the chapter after builds it for real.