---
title: TanStack Query + Next.js 16 App Router Prefetch (2026)
description: 'Prefetch TanStack Query v5 on the server in the Next.js 16 App Router, then hydrate with HydrationBoundary: no spinners, no double fetch. Runnable 2026 guide.'
url: https://nerdleveltech.com/tanstack-query-nextjs-app-router-prefetch-tutorial
language: en
date: '2026-06-17'
category: frontend
tags:
  - tanstack query
  - react query
  - next.js
  - app router
  - hydration
  - ssr
  - frontend
  - tutorial
markdown_url: https://nerdleveltech.com/tanstack-query-nextjs-app-router-prefetch-tutorial.md
attribution: 'Source: NerdLevelTech — https://nerdleveltech.com/tanstack-query-nextjs-app-router-prefetch-tutorial'
terms: https://nerdleveltech.com/llms.txt
---

# TanStack Query + Next.js 16 App Router Prefetch (2026)

To prefetch TanStack Query data in the Next.js App Router, call `prefetchQuery` on a per-request `QueryClient` inside a Server Component, then wrap the client subtree in `<HydrationBoundary state={dehydrate(queryClient)}>`. A matching `useQuery` on the client reads the data on first paint.

## TL;DR

You will fetch a list on the server in the Next.js 16 App Router and hand it to the client without a loading flash. The build uses `next` 16.2.9, `react` 19.2.7, and `@tanstack/react-query` 5.101.0.[^npm] You create one `QueryClient` factory that is a fresh client per request on the server and a singleton in the browser, prefetch in a Server Component, serialize the cache with `dehydrate`, and rehydrate it through `<HydrationBoundary>`. You then set a non-zero `staleTime` so the client does not immediately refetch, and finish with a streaming variant that uses `useSuspenseQuery`. Budget about 25 minutes.

## What you'll learn

- How to create a `QueryClient` factory that is per-request on the server and a singleton in the browser
- How to wire the `QueryClientProvider` into the App Router with a `'use client'` Providers component
- How to prefetch data in a Server Component with `prefetchQuery`
- How `dehydrate` and `HydrationBoundary` move the server cache to the client
- Why a prefetched query still refetches on mount, and how `staleTime` fixes it
- How to stream a pending query with `useSuspenseQuery` and `shouldDehydrateQuery`
- How to prefetch closer to where data is used, plus the single-client alternative
- How to verify hydration worked, and how to fix the common failure modes

## Prerequisites

- Node.js 20.9+ (Next.js 16 requires it; Node 24 LTS is the current Active LTS, supported through April 2028)[^node]
- A new Next.js 16 App Router app: `npx create-next-app@16.2.9 my-app --ts --app`
- Pinned packages, current on npm as of June 17, 2026:[^npm]

```bash
npm install --save-exact @tanstack/react-query@5.101.0 @tanstack/react-query-devtools@5.101.0
npm install --save-exact -D typescript@6.0.3 @types/react@19.2.17 @types/react-dom@19.2.3 @types/node@24.13.2
```

We will fetch from `https://jsonplaceholder.typicode.com`, a free public test API that needs no key, so every code block below runs as written.

First, a small data layer. Create `lib/posts.ts`:

```ts
export type Post = {
  id: number;
  title: string;
  body: string;
};

const API = "https://jsonplaceholder.typicode.com";

export async function getPosts(): Promise<Post[]> {
  const res = await fetch(`${API}/posts?_limit=10`);
  if (!res.ok) throw new Error(`Failed to load posts: ${res.status}`);
  return res.json() as Promise<Post[]>;
}
```

## Step 1: Create a QueryClient factory that is per-request on the server

A common App Router mistake is sharing one `QueryClient` across every request on the server. The server is long-lived, so a shared client leaks one user's data into the next user's cache. The fix is a factory that always builds a new client on the server, and reuses a single client in the browser. Create `app/get-query-client.ts`:

```ts
import {
  environmentManager,
  QueryClient,
  defaultShouldDehydrateQuery,
} from "@tanstack/react-query";

function makeQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: {
        // Above 0 so the client treats prefetched data as fresh
        // and does NOT refetch on mount.
        staleTime: 60 * 1000,
      },
      dehydrate: {
        // Also ship queries that are still pending, so streaming works.
        shouldDehydrateQuery: (query) =>
          defaultShouldDehydrateQuery(query) ||
          query.state.status === "pending",
      },
    },
  });
}

let browserQueryClient: QueryClient | undefined = undefined;

export function getQueryClient() {
  if (environmentManager.isServer()) {
    // Server: a brand-new client for every request.
    return makeQueryClient();
  }
  // Browser: reuse one client for the whole tab.
  if (!browserQueryClient) browserQueryClient = makeQueryClient();
  return browserQueryClient;
}
```

Two details matter here. `environmentManager.isServer()` is how current TanStack Query docs detect the server; in version 5.101.0 it is a function call. Older tutorials import a boolean instead (`import { isServer } from "@tanstack/react-query"` then `if (isServer)`) — that export still works but is now deprecated in favor of `environmentManager`, so do not let a copied snippet confuse you.[^isserver] The `staleTime` of 60 seconds and the `shouldDehydrateQuery` override both earn their place in later steps.

## Step 2: Wire up the QueryClientProvider

`QueryClientProvider` uses React context, so it must live in a Client Component. Create `app/providers.tsx`:

```tsx
"use client";

import { QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { getQueryClient } from "./get-query-client";

export default function Providers({ children }: { children: React.ReactNode }) {
  // No useState: getQueryClient() already returns a stable per-tab client.
  const queryClient = getQueryClient();

  return (
    <QueryClientProvider client={queryClient}>
      {children}
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
}
```

Note the comment: do not wrap `getQueryClient()` in `useState`. The browser singleton already guarantees one stable client per tab, and `useState` adds nothing here. Then mount the provider once in `app/layout.tsx`:

```tsx
import Providers from "./providers";

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}
```

## Step 3: TanStack Query server prefetching in a Server Component

Now the heart of it. A Server Component creates a client, awaits the prefetch, and passes the serialized cache down through `<HydrationBoundary>`.[^ssr] Create `app/posts/page.tsx`:

```tsx
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
import { getQueryClient } from "../get-query-client";
import { getPosts } from "../../lib/posts";
import PostList from "./posts";

export default async function PostsPage() {
  const queryClient = getQueryClient();

  // Fetch on the server and warm the cache for this exact query key.
  await queryClient.prefetchQuery({
    queryKey: ["posts"],
    queryFn: getPosts,
  });

  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <PostList />
    </HydrationBoundary>
  );
}
```

`prefetchQuery` runs `getPosts` on the server and stores the result under the key `["posts"]`. `dehydrate(queryClient)` serializes that cache into a plain object, and `HydrationBoundary` — itself a Client Component — rebuilds those entries into the browser client before your components render. The contract is the query key: the client must request the *exact* same key it was prefetched under.

## Step 4: HydrationBoundary and dehydrate on the client

The client reads the cache with an ordinary `useQuery`. Because the entry was hydrated, the data is present on the first render — there is no loading state to flash. Create `app/posts/posts.tsx`:

```tsx
"use client";

import { useQuery } from "@tanstack/react-query";
import { getPosts } from "../../lib/posts";

export default function PostList() {
  // Same queryKey as the server prefetch -> data is here on first paint.
  const { data, isPending, isError, error } = useQuery({
    queryKey: ["posts"],
    queryFn: getPosts,
  });

  if (isPending) return <p>Loading posts...</p>;
  if (isError) return <p>Something went wrong: {error.message}</p>;

  return (
    <ul>
      {data.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}
```

The `queryFn` is still here, and that is intentional: it is the fallback for client-side navigations to this route where no server prefetch ran, and the refetch source once the data goes stale. On the prefetched first load it simply is not called.

## Step 5: The staleTime that stops the double fetch

Here is the subtle trap. TanStack Query's default `staleTime` is `0`, which means a query is considered stale the instant it is cached. So even with a perfect prefetch, the client mounts, sees a "stale" entry, and immediately refetches in the background — your server work was wasted and the network tab shows a duplicate request. Setting `staleTime` above zero in Step 1 is the fix: for the first 60 seconds the hydrated data is "fresh," so the client renders it and skips the refetch.

You can confirm the mechanism directly. Right after a prefetch with `staleTime: 60 * 1000`, the cached query reports `isStale() === false`, which is exactly why no refetch fires on mount. Raise or lower the window to match how quickly your data changes; just keep it above zero whenever you prefetch.

## Step 6: Streaming a pending query with useSuspenseQuery

Awaiting the prefetch blocks the response until the data is ready. For slower data you can instead *stream*: start the fetch, ship the still-pending query, and let it resolve over the wire into a Suspense boundary.[^ssr] This is why `makeQueryClient` overrode `shouldDehydrateQuery` to also include `pending` queries — the default drops them. Create `app/posts-streaming/page.tsx`:

```tsx
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
import { Suspense } from "react";
import { getQueryClient } from "../get-query-client";
import { getPosts } from "../../lib/posts";
import StreamingPostList from "./posts";

// No `async`, no `await`: kick off the fetch and stream the result in.
export default function StreamingPostsPage() {
  const queryClient = getQueryClient();

  // Note: no await. The pending query is dehydrated and streamed.
  void queryClient.prefetchQuery({
    queryKey: ["posts"],
    queryFn: getPosts,
  });

  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <Suspense fallback={<p>Streaming posts...</p>}>
        <StreamingPostList />
      </Suspense>
    </HydrationBoundary>
  );
}
```

The client component uses `useSuspenseQuery`, which never returns `undefined` data and suspends until the entry resolves. Create `app/posts-streaming/posts.tsx`:

```tsx
"use client";

import { useSuspenseQuery } from "@tanstack/react-query";
import { getPosts } from "../../lib/posts";

export default function StreamingPostList() {
  // Suspends until ready; data is always defined here.
  const { data } = useSuspenseQuery({
    queryKey: ["posts"],
    queryFn: getPosts,
  });

  return (
    <ul>
      {data.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}
```

This works because React can serialize the pending promise to the client, and the `shouldDehydrateQuery` override puts the pending query into the dehydrated state in the first place.

## Prefetch closer to where data is used

You are not limited to prefetching in `page.tsx`. Because Server Components nest, you can prefetch in `layout.tsx` or in any nested Server Component, dropping a `<HydrationBoundary>` around just the subtree that needs the data. Multiple boundaries on one page are fine — each can create its own client and dehydrate its own slice. The reason to prefetch deeper is parallelism: Next.js fetches route segments in parallel, so spreading prefetches across a layout, a page, and parallel routes lets the framework flatten what would otherwise be a server-side request waterfall.[^ssr]

If you prefer one client for the whole request instead of one per Server Component, wrap a single factory in React's `cache`, which is scoped per request so nothing leaks between users:

```ts
// app/get-query-client.ts (single-client-per-request alternative)
import { QueryClient } from "@tanstack/react-query";
import { cache } from "react";

// cache() is scoped per request, so data never leaks between requests.
export const getQueryClient = cache(() => new QueryClient());
```

The tradeoff is that every `dehydrate(getQueryClient())` call serializes the *entire* client, including queries from unrelated Server Components, so you pay a little extra serialization for the convenience of grabbing the same client anywhere.[^ssr]

## Verification

Run `npm run dev` and open `/posts`. Three checks confirm hydration worked:

1. **View source.** The rendered `<li>` titles are present in the initial HTML, not injected later by JavaScript. If the list is missing from the raw HTML, the prefetch did not run.
2. **Network tab.** On a hard reload of `/posts`, there is no client-side request to the posts API in the first 60 seconds. A duplicate request means `staleTime` is still `0`.
3. **React Query Devtools.** The `["posts"]` query appears already in a `success` state on first paint, rather than transitioning from `pending`.

You can also verify the dehydration contract in isolation, without a browser. Save this as `verify.mjs` and run `node verify.mjs`:

```js
import { QueryClient, dehydrate } from "@tanstack/react-query";

const sample = [{ id: 1, title: "first", body: "..." }];

// Awaited prefetch -> the query is captured with its data.
const qc = new QueryClient();
await qc.prefetchQuery({ queryKey: ["posts"], queryFn: async () => sample });
console.log("queries dehydrated:", dehydrate(qc).queries.length); // 1

// A pending query is dropped UNLESS you opt it in.
const qc2 = new QueryClient();
void qc2.prefetchQuery({
  queryKey: ["posts"],
  queryFn: () => new Promise((r) => setTimeout(() => r(sample), 50)),
});
console.log("default pending dehydrated:", dehydrate(qc2).queries.length); // 0
```

The first log prints `1` and the second prints `0`. That second `0` is the exact reason streaming needs the `shouldDehydrateQuery` override from Step 1.

## Troubleshooting

**`dehydrate()` returns an empty `{ queries: [] }`.** Either you did not `await` the prefetch (so the query was still pending when you dehydrated, and the default config drops pending queries), or you dehydrated a *different* `QueryClient` than the one you prefetched on. Await the prefetch for the blocking pattern, or add the `pending` override and a Suspense boundary for the streaming pattern.[^issue]

**The data is correct, but the client refetches immediately on mount.** This is the `staleTime: 0` default. Set a non-zero `staleTime` in `makeQueryClient` so hydrated data is treated as fresh.[^ssr]

**`environmentManager.isServer is not a function`.** You are on a TanStack Query version older than the one that introduced `environmentManager` (added in pull request #10199). Either upgrade to 5.101.0, or use the still-exported boolean instead: `import { isServer } from "@tanstack/react-query"` and `if (isServer)`.[^isserver]

**The query key does not match.** Hydration is keyed by the serialized query key. `["posts"]` on the server and `["posts", undefined]` on the client are different keys, so the client treats the entry as a miss and fetches again. Keep the keys byte-for-byte identical.

**You tried to fetch with a Server Action in the `queryFn`.** TanStack Query advises against this: Server Actions run serially when called from the client, which conflicts with how Query fetches and refetches, and can leave a query stuck pending. Fetch from a route handler or an RPC layer instead, and reserve Server Actions for mutations.[^serveraction]

## Next steps and further reading

You now have server-prefetched, hydrated TanStack Query data with no loading flash and no duplicate fetch. From here, pair this with [Next.js 16 streaming and Suspense with `use cache`](https://nerdleveltech.com/nextjs-16-streaming-suspense-use-cache-tutorial) to control how the streamed shell renders, and use [Server Actions and optimistic UI in Next.js 16](https://nerdleveltech.com/nextjs-16-server-actions-react-19-optimistic-ui-tutorial) for the mutation side that `useMutation` pairs with. If you also use TanStack Router, the same key-based discipline shows up in [type-safe search params with TanStack Router](https://nerdleveltech.com/tanstack-router-type-safe-search-params-zod-tutorial).

[^npm]: Versions confirmed on the npm registry on 2026-06-17: `next` 16.2.9, `react`/`react-dom` 19.2.7, `@tanstack/react-query` and `@tanstack/react-query-devtools` 5.101.0, `typescript` 6.0.3, `@types/react` 19.2.17, `@types/react-dom` 19.2.3, `@types/node` 24.13.2. <https://www.npmjs.com/package/@tanstack/react-query>
[^node]: Node.js release schedule (Node 24 "Krypton" Active LTS): <https://github.com/nodejs/release> — and Next.js 16 declares `engines.node` `>=20.9.0`.
[^ssr]: "Advanced Server Rendering," TanStack Query v5 React docs (the `get-query-client`, `staleTime`, `shouldDehydrateQuery`, and streaming patterns): <https://tanstack.com/query/v5/docs/framework/react/guides/advanced-ssr>
[^isserver]: In `@tanstack/react-query` 5.101.0, `environmentManager.isServer` is a function and the older `isServer` is a boolean; both are exported, and the boolean `isServer` is now deprecated in favor of `environmentManager.isServer()` (TanStack/query PR #10199). Verified against the installed package.
[^serveraction]: TanStack Query's Advanced Server Rendering guide warns against using Next.js Server Actions to fetch inside a `queryFn` (it cites issues TanStack/query #7934 and #6264): <https://tanstack.com/query/v5/docs/framework/react/guides/advanced-ssr>
[^issue]: Recurring App Router reports that a prefetched query still refetches on the client on mount, or that `HydrationBoundary` fails to deliver prefetched data to a consumer: TanStack/query Discussion #7184 ("useQuery fetches in client upon mount") and Issue #8479 ("HydrationBoundary not working if prefetched query accessed via layout"). <https://github.com/TanStack/query/discussions/7184>
