frontend

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

June 17, 2026

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.1 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)2
  • 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:1
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:

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:

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.3 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:

"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:

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>.4 Create app/posts/page.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:

"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.4 This is why makeQueryClient overrode shouldDehydrateQuery to also include pending queries — the default drops them. Create app/posts-streaming/page.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:

"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.4

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:

// 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.4

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:

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.5

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.4

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).3

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.6

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 to control how the streamed shell renders, and use Server Actions and optimistic UI in Next.js 16 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.

Footnotes

  1. 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 2

  2. Node.js release schedule (Node 24 "Krypton" Active LTS): https://github.com/nodejs/release — and Next.js 16 declares engines.node >=20.9.0.

  3. 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. 2

  4. "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 2 3 4 5

  5. 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

  6. 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