Server-Side Rendering with TanStack Query: SSR, SSG, ISR
TanStack Query (formerly React Query) has become the go-to data-fetching library for React applications thanks to its powerful caching, deduplication, and synchronization features. But when you move beyond the client and into server-rendered territory — whether that's classic SSR, static generation, or incremental regeneration — things get more nuanced. This tutorial walks you through how to integrate TanStack Query with each rendering strategy, why it matters, and the patterns that keep your app fast and bug-free.
Why Server Rendering Matters with TanStack Query
By default, TanStack Query runs entirely on the client. The first render produces an empty query cache, then a network request fires, and the UI updates once data arrives. That works fine for dashboards and internal tools, but it has two big downsides: a slower perceived load time (the user sees a spinner before content) and weaker SEO (crawlers may not wait for client-side fetches).
Server rendering solves both problems by populating the query cache before the React tree reaches the browser. TanStack Query supports this through a mechanism called hydration: the server prefetches data, serializes the cache, sends it to the client along with the HTML, and the client "rehydrates" the cache so the first paint already contains real data and no redundant requests are made.
Core Concepts: Prefetch, Dehydrate, Hydrate
Regardless of which rendering strategy you use, the workflow is always the same three steps:
- Prefetch — On the server, create a QueryClient, call
queryClient.prefetchQuery()(orfetchQuery) for each query your page needs. - Dehydrate — Extract the current state of the cache with
dehydrate(queryClient). This produces a plain serializable object. - Hydrate — On the client, pass that dehydrated state into a fresh
QueryClientviaHydrationBoundary(or the olderhydrateAPI). React then renders with data already in the cache.
The key rule: never reuse a single QueryClient across requests. Each server request needs its own instance so users don't leak data between sessions.
Setting Up the Foundations
Let's start with shared infrastructure that all three strategies will use. We'll assume a React 18+ app with React Router v7 (or Remix/Next.js — the patterns translate). First, install the dependencies:
npm install @tanstack/react-query
Create a factory function that builds a fresh client each time it's called:
// lib/getQueryClient.ts
import { QueryClient, isServer } from '@tanstack/react-query';
function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 minute
refetchOnWindowFocus: false,
},
},
});
}
let browserQueryClient: QueryClient | undefined;
export function getQueryClient() {
if (isServer) {
// Server: always make a new client
return makeQueryClient();
}
// Browser: reuse the client across renders
if (!browserQueryClient) {
browserQueryClient = makeQueryClient();
}
return browserQueryClient;
}
This pattern ensures the server always starts with an empty cache, while the browser keeps a stable client instance so cache persists across client-side navigations.
Server-Side Rendering (SSR)
SSR renders the page on every request. It's ideal for highly dynamic, user-specific content — think dashboards, account pages, or anything behind authentication. The server fetches fresh data, renders HTML, and ships it down with the dehydrated cache.
Prefetching in the Loader
With React Router v7 (or Remix), data prefetching happens in a route loader. Here's how to prefetch a list of posts:
// routes/posts.tsx
import { dehydrate, HydrationBoundary } from '@tanstack/react-query';
import { getQueryClient } from '~/lib/getQueryClient';
import { fetchPosts } from '~/api/posts';
import PostsList from '~/components/PostsList';
export async function loader() {
const queryClient = getQueryClient();
await queryClient.prefetchQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
});
return {
dehydratedState: dehydrate(queryClient),
};
}
export default function PostsRoute() {
const { dehydratedState } = useLoaderData();
return (
<HydrationBoundary state={dehydratedState}>
<PostsList />
</HydrationBoundary>
);
}
The PostsList component then uses useQuery normally — it doesn't need to know whether data came from the server or the client:
// components/PostsList.tsx
import { useQuery } from '@tanstack/react-query';
import { fetchPosts } from '~/api/posts';
export default function PostsList() {
const { data, isLoading, error } = useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
});
if (isLoading) return <p>Loading...</p>;
if (error) return <p>Error loading posts</p>;
return (
<ul>
{data.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
Handling Errors During Prefetch
By default, prefetchQuery swallows errors so a failed prefetch doesn't crash the server render. The client will retry the query after hydration. If you want to surface errors immediately (for example, to render a 404 page), use fetchQuery instead and catch the error:
export async function loader({ params }) {
const queryClient = getQueryClient();
try {
await queryClient.fetchQuery({
queryKey: ['post', params.id],
queryFn: () => fetchPost(params.id),
});
} catch (err) {
throw new Response('Not Found', { status: 404 });
}
return { dehydratedState: dehydrate(queryClient) };
}
Static Site Generation (SSG)
SSG pre-renders pages at build time. The HTML and the dehydrated cache are written to disk and served as static files. This is perfect for content that rarely changes: blog posts, documentation, marketing pages. The trade-off is that data is only as fresh as the last build.
Prefetching at Build Time
The mechanics are identical to SSR — you still prefetch, dehydrate, and hydrate. The difference is when it runs. With a static build script, you generate pages once and write the output to files:
// scripts/build-static.ts
import { dehydrate } from '@tanstack/react-query';
import { getQueryClient } from './lib/getQueryClient';
import { fetchPosts, fetchPost } from './api/posts';
import { renderToString } from 'react-dom/server';
import fs from 'node:fs';
import path from 'node:path';
async function build() {
const queryClient = getQueryClient();
const posts = await queryClient.fetchQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
});
// Build the index page
const indexState = dehydrate(queryClient);
const indexHtml = renderToString(/* <App dehydratedState={indexState} /> */);
fs.writeFileSync(path.join('dist', 'index.html'), wrapHtml(indexHtml, indexState));
// Build each post page
for (const post of posts) {
const postClient = getQueryClient();
await postClient.prefetchQuery({
queryKey: ['post', post.id],
queryFn: () => fetchPost(post.id),
});
const postState = dehydrate(postClient);
const postHtml = renderToString(/* <PostApp id={post.id} dehydratedState={postState} /> */);
fs.writeFileSync(
path.join('dist', 'posts', `${post.id}.html`),
wrapHtml(postHtml, postState)
);
}
}
function wrapHtml(content: string, state: unknown) {
return `<!DOCTYPE html><html><body>
<div id="root">${content}</div>
<script>window.__DEHYDRATED_STATE__ = ${JSON.stringify(state)}</script>
<script src="/main.js"></script>
</body></html>`;
}
build();
On the client, read the embedded state and pass it to HydrationBoundary at the root:
// main.tsx (client entry)
import { HydrationBoundary, QueryClientProvider } from '@tanstack/react-query';
import { getQueryClient } from './lib/getQueryClient';
import App from './App';
const queryClient = getQueryClient();
const dehydratedState = window.__DEHYDRATED_STATE__;
createRoot(document.getElementById('root')!).render(
<QueryClientProvider client={queryClient}>
<HydrationBoundary state={dehydratedState}>
<App />
</HydrationBoundary>
</QueryClientProvider>
);
When to Choose SSG
- Content changes infrequently (docs, blogs, landing pages).
- You want maximum performance — static files are served from a CDN edge.
- SEO is critical and content is public.
- You can tolerate stale data until the next deploy.
Incremental Static Regeneration (ISR)
ISR blends SSG's speed with SSR's freshness. Pages are generated statically, but a background process revalidates them on a schedule or on-demand. When new data arrives, the static page is rebuilt and swapped in. The next visitor gets the fresh version; the current visitor still gets the cached one instantly.
TanStack Query doesn't ship an ISR engine itself — that's the framework's job (Next.js, Remix, custom infrastructure). What TanStack Query provides is a clean way to prefetch and dehydrate the data that the ISR pipeline then caches. Here's an example using Next.js App Router:
// app/posts/[id]/page.tsx
import { dehydrate, HydrationBoundary } from '@tanstack/react-query';
import { getQueryClient } from '~/lib/getQueryClient';
import { fetchPost } from '~/api/posts';
import PostView from '~/components/PostView';
export const revalidate = 60; // regenerate at most once per 60 seconds
export async function generateStaticParams() {
const posts = await fetchAllPostIds();
return posts.map((id) => ({ id }));
}
export default async function Page({ params }: { params: { id: string } }) {
const queryClient = getQueryClient();
await queryClient.prefetchQuery({
queryKey: ['post', params.id],
queryFn: () => fetchPost(params.id),
});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<PostView id={params.id} />
</HydrationBoundary>
);
}
Here's what happens on each request:
- Next.js checks whether a cached static page exists and is younger than
revalidateseconds. - If yes, it serves the cached HTML and dehydrated state immediately.
- If the cache is stale, it serves the stale version and triggers a background regeneration. The regeneration runs the loader again, prefetches fresh data, and overwrites the cached page.
- If no cache exists (first visit or new path), it generates the page on demand and caches the result.
On-Demand Revalidation
For content that should update the instant it changes (a CMS publish event, for example), use on-demand revalidation. The CMS calls a webhook, which tells the framework to regenerate specific paths:
// app/api/revalidate/route.ts
import { revalidatePath } from 'next/cache';
export async function POST(req: Request) {
const { path, secret } = await req.json();
if (secret !== process.env.REVALIDATE_SECRET) {
return new Response('Unauthorized', { status: 401 });
}
revalidatePath(path);
return Response.json({ revalidated: true, path });
}
The next visitor after the webhook fires gets the freshly regenerated page with updated TanStack Query cache — no rebuild, no redeploy.
Best Practices
1. Always Create a Fresh QueryClient on the Server
The single most common SSR bug is sharing a QueryClient across requests. Because the cache lives on the client instance, reuse means User A's data leaks into User B's response. Always instantiate per request, as shown in getQueryClient above.
2. Set a Sensible staleTime
Without a staleTime, queries are considered stale immediately. After hydration, React Query will refetch on mount — defeating the purpose of server rendering. Set staleTime to at least a few seconds (or longer for SSG/ISR) so the client trusts the dehydrated data:
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // trust server data for 1 minute
},
},
});
3. Prefetch Only What's Above the Fold
Prefetching every query on the server slows down the response. Prefetch the critical path — the data needed for the initial render — and let the client fetch the rest lazily. You can use prefetchQuery for non-blocking prefetches and fetchQuery only when the data is required for the render.
4. Be Careful with Sensitive Data
Everything in the dehydrated state is serialized into the HTML and visible to anyone who views source. Don't prefetch user-specific secrets, internal IDs you don't want exposed, or data the current viewer shouldn't see. Filter the dehydrated state if needed:
const state = dehydrate(queryClient);
state.queries = state.queries.filter(
(q) => !q.queryKey.includes('private')
);
5. Use Stable Query Keys
Hydration matches server and client queries by key. If keys differ between server and client (for example, due to a function or Date in the key), hydration silently fails and the client refetches. Keep keys plain, serializable, and deterministic.
6. Handle Non-Serializable Data
The dehydrated state must be JSON-serializable. If your queryFn returns Dates, Maps, or class instances, transform them to plain objects before they hit the cache, or use a custom serializer. A common approach is to normalize in the queryFn:
const fetchPost = async (id: string) => {
const res = await fetch(`/api/posts/${id}`);
const raw = await res.json();
return {
...raw,
publishedAt: raw.publishedAt ? new Date(raw.publishedAt).toISOString() : null,
};
};
7. Test the Hydration Boundary
A subtle bug: if the server renders one value and the client renders another (because the data changed between request and hydration), React throws a hydration mismatch. To avoid this, ensure the client's first render uses the dehydrated state verbatim. Don't mutate query data in useEffect before hydration completes, and avoid Date.now() or Math.random() in render output.
Putting It All Together
The beauty of TanStack Query's SSR story is that the component code is identical across all three strategies. The same useQuery call works whether the data was prefetched on every request (SSR), at build time (SSG), or regenerated in the background (ISR). What changes is the surrounding infrastructure: where and when prefetching happens, how the dehydrated state is delivered, and how often the cache is refreshed.
Choose SSR for dynamic, user-specific pages where freshness beats speed. Choose SSG for static content where you want maximum performance and can tolerate stale data until the next build. Choose ISR when you want SSG's speed but need periodic updates without redeploying. In all three cases, TanStack Query gives you a unified, type-safe, cache-first data layer that works the same on the server and the client — and that consistency is what makes scaling server-rendered React applications manageable.