TanStack Query with Next.js App Router: Server Prefetching, Hydration, and Mutations (2026)

Prefetch on the server, hydrate on the client. A 2026 guide to using TanStack Query v5 with the Next.js 16 App Router: HydrationBoundary, streaming, Server Actions, and the common hydration errors that break RSC setups.

TanStack Query in Next.js 16 (2026)

Updated: August 11, 2026

TanStack Query works with the Next.js App Router by pairing server-side prefetchQuery calls with a client-side HydrationBoundary. The server renders the initial data into the RSC payload, then the client "rehydrates" a shared QueryClient so that useQuery hooks resolve instantly without a refetch. In this 2026 guide I'll walk through the exact setup for Next.js 16 and React 19, including streaming, mutations with revalidateTag, and the mistakes that cause "hydration mismatch" errors. (I hit most of these the hard way on a dashboard rewrite last spring.)

  • Server Components and TanStack Query are complementary. Use RSC for the initial payload and TanStack Query for anything that needs client-side caching, refetching, or optimistic updates.
  • You need exactly one QueryClient per request on the server and a singleton per browser tab on the client. A shared module-level client leaks data between users.
  • Prefetch on the server with queryClient.prefetchQuery, wrap the client boundary in <HydrationBoundary state={dehydrate(queryClient)}>, then call useSuspenseQuery in the child component.
  • Streaming SSR works via <HydrationBoundary>. Dehydrated queries flush progressively into the HTML as each Suspense boundary resolves.
  • For mutations, combine useMutation with a Server Action that calls revalidateTag, then invalidate the client cache in onSettled.
  • Devtools should be lazy-loaded in a Client Component so they don't ship in production bundles.

Why use TanStack Query alongside Server Components?

The most common question I get is "do I still need TanStack Query if I have Server Components?" Honestly, the answer is: only for the parts of your UI that need it. Server Components handle the first render beautifully. They can await fetch() directly, participate in the Next.js Data Cache, and never ship JavaScript for the fetching logic. But the moment a page needs to refetch on window focus, mutate optimistically, poll a status endpoint, share a cache across tabs via broadcastQueryClient, or paginate an infinite list without a full navigation, you're back in the world of client-side query state.

TanStack Query v5 fills that gap. In practice, most App Router projects I've shipped in 2026 use a hybrid setup. Server Components render the first paint from the RSC payload, then TanStack Query takes over for interactive lists, dashboards, and any polling widgets. The HydrationBoundary API (introduced in v5 as a replacement for the older Hydrate component) is the glue that lets you prefetch on the server and continue rendering on the client without a network round-trip. If you're new to RSC data fetching itself, our Next.js streaming and Suspense guide is a good starting point before layering client state on top.

The other reason to bother is consistency. A large app might have dozens of endpoints. Keeping some in fetch caches and others in ad-hoc useEffect code becomes a maintenance nightmare pretty quickly. Standardising on TanStack Query for anything that involves client-side revalidation gives you retries, cache dedup, structural sharing, and a single mental model. All the things vanilla fetch hooks reinvent badly.

Setting up the QueryClient provider correctly

The provider setup is the single place where most tutorials get it wrong. On the server, a module-level const queryClient = new QueryClient() is shared between every incoming request, meaning User A's cached data can leak into User B's response. On the client, a per-render useState-created client wipes the cache on every fast refresh. You need a function that behaves differently in each environment.

Install the packages first:

npm install @tanstack/react-query @tanstack/react-query-devtools

Then create a shared factory. This pattern comes straight from the official TanStack Query advanced SSR guide and it's the one you should be using in Next.js 16:

// app/get-query-client.ts
import {
  QueryClient,
  defaultShouldDehydrateQuery,
  isServer,
} from '@tanstack/react-query'

function makeQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: {
        // With SSR we usually want to set some default staleTime
        // above 0 to avoid an immediate refetch on the client.
        staleTime: 60 * 1000,
      },
      dehydrate: {
        // Include pending queries so streaming works.
        shouldDehydrateQuery: (query) =>
          defaultShouldDehydrateQuery(query) ||
          query.state.status === 'pending',
      },
    },
  })
}

let browserQueryClient: QueryClient | undefined = undefined

export function getQueryClient() {
  if (isServer) {
    // Server: always make a new query client
    return makeQueryClient()
  }
  // Browser: make a new query client if we don't already have one
  browserQueryClient ??= makeQueryClient()
  return browserQueryClient
}

The isServer check is critical. In App Router, this file is imported by both Server Components (for prefetching) and Client Components (for the provider). Returning a fresh client on the server ensures request isolation. Caching the browser client behind a module-level variable ensures it survives React 19 concurrent renders.

Now wire up the provider as a Client Component that lives inside app/layout.tsx:

// app/providers.tsx
'use client'

import { QueryClientProvider } from '@tanstack/react-query'
import { getQueryClient } from './get-query-client'

export default function Providers({ children }: { children: React.ReactNode }) {
  // NOTE: Avoid useState(() => new QueryClient()) — that resets on suspense.
  const queryClient = getQueryClient()

  return (
    <QueryClientProvider client={queryClient}>
      {children}
    </QueryClientProvider>
  )
}

Server-side prefetching with HydrationBoundary

Here's the pattern that unlocks the whole point of using TanStack Query in the App Router: prefetch on the server, hydrate on the client. Say you have a dashboard that shows a list of projects. On the server, you'll create a QueryClient, prefetch the query, then pass the dehydrated state down to a Client Component tree.

// app/dashboard/page.tsx  (Server Component)
import {
  dehydrate,
  HydrationBoundary,
  QueryClient,
} from '@tanstack/react-query'
import { getQueryClient } from '@/app/get-query-client'
import { ProjectList } from './project-list'
import { fetchProjects } from '@/lib/api'

export default async function DashboardPage() {
  const queryClient = getQueryClient()

  // Prefetch on the server. The promise is awaited so the data
  // is available before we dehydrate.
  await queryClient.prefetchQuery({
    queryKey: ['projects'],
    queryFn: fetchProjects,
  })

  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <ProjectList />
    </HydrationBoundary>
  )
}

The Client Component that reads the data uses useSuspenseQuery. It will resolve immediately from the hydrated cache on the first render, then behave like a normal query on subsequent interactions (refetching on focus, invalidating on mutations, and so on):

// app/dashboard/project-list.tsx
'use client'

import { useSuspenseQuery } from '@tanstack/react-query'
import { fetchProjects } from '@/lib/api'

export function ProjectList() {
  const { data } = useSuspenseQuery({
    queryKey: ['projects'],
    queryFn: fetchProjects,
  })

  return (
    <ul>
      {data.map((project) => (
        <li key={project.id}>{project.name}</li>
      ))}
    </ul>
  )
}

Notice the fetchProjects function is shared between both files. This is deliberate. The queryKey and queryFn must match exactly for the cache to hydrate. If you use fetchProjects() on the server and fetchProjectsClient() on the client with the same key, TanStack will still hydrate the data but will refetch immediately, defeating the point.

Streaming SSR and progressive hydration

One of the most underused features of the App Router is streaming. The server can flush HTML in chunks as each Suspense boundary resolves. TanStack Query v5 supports this out of the box, but you have to opt in by not awaiting the prefetch. Instead, you use void queryClient.prefetchQuery and let the pending promise get dehydrated as part of the boundary state:

// Streaming variant — do NOT await
export default function DashboardPage() {
  const queryClient = getQueryClient()

  // Fire-and-forget: the promise gets serialized into the HTML.
  void queryClient.prefetchQuery({
    queryKey: ['projects'],
    queryFn: fetchProjects,
  })

  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <Suspense fallback={<ProjectListSkeleton />}>
        <ProjectList />
      </Suspense>
    </HydrationBoundary>
  )
}

This pattern only works because we set shouldDehydrateQuery to include pending queries in the makeQueryClient factory earlier. Without that flag, dehydration would ignore in-flight requests and the client would refetch from scratch. The result is a Time to First Byte around the standard App Router baseline, with the projects data streamed in as soon as the API responds. Great for slow database queries or third-party APIs that you don't want blocking the initial paint.

If you're new to Suspense in Next.js, our deep-dive on streaming and Suspense loading states covers the fundamentals of chunked responses and skeleton design. TanStack Query slots into that model without any special wiring. Each <Suspense> becomes an independent hydration boundary.

Mutations, Server Actions, and cache invalidation

Mutations are where the two worlds (Server Actions and TanStack Query) need to cooperate deliberately. My recommended pattern for 2026: use a Server Action for the actual write, so you get FormData, progressive enhancement, and CSRF protection for free. Then invalidate the TanStack cache in the mutation's onSettled callback:

// app/dashboard/actions.ts
'use server'

import { revalidateTag } from 'next/cache'
import { db } from '@/lib/db'

export async function createProject(formData: FormData) {
  const name = formData.get('name') as string
  const project = await db.project.create({ data: { name } })
  revalidateTag('projects')
  return project
}
// app/dashboard/new-project-form.tsx
'use client'

import { useMutation, useQueryClient } from '@tanstack/react-query'
import { createProject } from './actions'

export function NewProjectForm() {
  const queryClient = useQueryClient()

  const mutation = useMutation({
    mutationFn: (formData: FormData) => createProject(formData),
    onSettled: () => {
      // Refetch the projects list after mutation completes.
      queryClient.invalidateQueries({ queryKey: ['projects'] })
    },
  })

  return (
    <form action={(fd) => mutation.mutate(fd)}>
      <input name="name" required />
      <button disabled={mutation.isPending}>
        {mutation.isPending ? 'Creating…' : 'Create'}
      </button>
    </form>
  )
}

The revalidateTag call handles cache invalidation for any Server Component reads on subsequent navigations, and invalidateQueries handles the client cache for the current tab. Both are needed. They operate on separate caches. For a deeper look at Server Actions themselves, see our complete guide to Next.js Server Actions.

For optimistic updates that need to feel instant, TanStack's onMutate callback lets you write to the cache before the server responds. If you want the React-native equivalent using useOptimistic, we cover that in the optimistic UI guide. Pick one pattern per feature. Mixing them causes race conditions where the two caches disagree, and honestly, debugging that at 2 a.m. is not fun.

Infinite queries and pagination patterns

Infinite scrolling is one of the strongest arguments for keeping TanStack Query in the stack. RSC has no built-in equivalent. You'd have to reload the entire page to fetch the next chunk. With useSuspenseInfiniteQuery, the initial page is prefetched on the server, the rest are fetched on demand, and all pages share a single cache entry:

// Server prefetch
await queryClient.prefetchInfiniteQuery({
  queryKey: ['posts'],
  queryFn: ({ pageParam }) => fetchPosts({ cursor: pageParam }),
  initialPageParam: null,
  pages: 1,
})
// Client consumer
'use client'
import { useSuspenseInfiniteQuery } from '@tanstack/react-query'

export function PostFeed() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
  } = useSuspenseInfiniteQuery({
    queryKey: ['posts'],
    queryFn: ({ pageParam }) => fetchPosts({ cursor: pageParam }),
    initialPageParam: null,
    getNextPageParam: (last) => last.nextCursor,
  })

  return (
    <>
      {data.pages.flatMap((p) => p.items).map((item) => (
        <article key={item.id}>{item.title}</article>
      ))}
      {hasNextPage && (
        <button
          onClick={() => fetchNextPage()}
          disabled={isFetchingNextPage}
        >
          {isFetchingNextPage ? 'Loading…' : 'Load more'}
        </button>
      )}
    </>
  )
}

The pages: 1 option on the server tells the prefetcher to only fetch the first page, keeping the initial payload small. For an intersection-observer-driven infinite scroll, wrap the "Load more" button in an IntersectionObserver hook and call fetchNextPage() when it enters the viewport.

Adding the DevTools without shipping them to production

The React Query DevTools are indispensable for debugging cache state, but they add roughly 40 KB gzipped to your client bundle. In 2026 the accepted pattern is a lazy dynamic import in your Providers file, gated on process.env.NODE_ENV:

// app/providers.tsx
'use client'

import { QueryClientProvider } from '@tanstack/react-query'
import { getQueryClient } from './get-query-client'
import dynamic from 'next/dynamic'

const ReactQueryDevtools =
  process.env.NODE_ENV === 'production'
    ? () => null
    : dynamic(() =>
        import('@tanstack/react-query-devtools').then((d) => d.ReactQueryDevtools),
      )

export default function Providers({ children }: { children: React.ReactNode }) {
  const queryClient = getQueryClient()
  return (
    <QueryClientProvider client={queryClient}>
      {children}
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  )
}

Because Next.js tree-shakes the production branch, the DevTools code never enters the client chunk. You can verify this by running ANALYZE=true next build if you have the bundle analyzer configured.

Common errors and how to fix them

"No QueryClient set, use QueryClientProvider to set one"

You're calling a hook outside the provider tree. Check that <Providers> is the outermost Client Component in layout.tsx, not nested inside a Server Component boundary that hasn't rendered yet.

"Hydration failed because the initial UI does not match…"

Almost always caused by a mismatch between the server prefetch and the client query. Different queryKeys, different query functions, or a query function that reads window/Date.now(). Log the dehydrated state on the server and the mounted state on the client to compare. If your query function has to read from localStorage, wrap that part in a typeof window !== 'undefined' guard and provide a fallback. I hit this exact bug shipping a feature-flag panel, and the fix was as boring as adding one guard.

Data leaks between users on Vercel

You're sharing a module-level QueryClient across requests. Refactor to use the getQueryClient pattern from earlier. The isServer branch must always return a new instance. This is the single most dangerous bug in RSC + TanStack Query setups and it only manifests under load, which is why it usually escapes local testing.

fetch requests happening twice, once on server, once on client

Either the queryKey doesn't match between server and client, or your staleTime is 0. Set a sensible default (60 s is a good baseline) in makeQueryClient so hydrated queries aren't immediately considered stale. If you also want to double-check where those extra requests come from, the browser Network panel plus the React StrictMode documentation are your friends. Strict mode double-invokes some hooks in dev, which trips people up more often than you'd expect.

Frequently Asked Questions

Can you use TanStack Query with Server Components?

Yes. You prefetch data in a Server Component using queryClient.prefetchQuery, dehydrate the result into a <HydrationBoundary>, and consume it in a Client Component with useSuspenseQuery. TanStack Query hooks themselves must run in Client Components, but their initial data can come from RSC prefetching so there's no client-side network round-trip on first render.

Is TanStack Query still needed if I have React Server Components?

Only for UI that needs client-side cache behaviour: refetch-on-focus, polling, optimistic updates, infinite scroll, or cross-component cache sharing. If a component just renders once and never changes, plain await fetch() in a Server Component is simpler and ships zero JavaScript.

How do you prefetch data in Next.js App Router with TanStack Query?

Create a per-request QueryClient in the Server Component, call await queryClient.prefetchQuery({ queryKey, queryFn }), then pass dehydrate(queryClient) as the state prop of a <HydrationBoundary> wrapping your Client Components. For streaming, use void instead of await and rely on the pending-query dehydration option.

What is the difference between fetch caching and TanStack Query caching?

Next.js's fetch cache is a server-side cache keyed by URL and tags, invalidated via revalidateTag. TanStack Query is a client-side cache keyed by an arbitrary queryKey, invalidated via invalidateQueries. They operate on different layers, and a mutation typically needs to invalidate both.

Why is my TanStack Query refetching immediately after hydration?

The default staleTime is 0, which means hydrated data is considered stale the moment it arrives. Set a default in your QueryClient options (60 seconds is a reasonable baseline) so the hydrated cache is trusted for at least the initial render.

Editorial Team
About the Author Editorial Team

Our team of expert writers and editors.