Skip to content
NEXT.JS

The Ultimate Guide to TanStack Query in Next.js 15

Unlock powerful data fetching in Next.js 15. Learn how TanStack Query and Server Components create blazing-fast apps with advanced caching and mutations.

18 min readBy Daniel Olawoyinnext.js 15 · tanstack query · react query

I'll be honest. When the Next.js App Router and Server Components first landed, a part of me wondered, "Is this the end for client-side data fetching libraries like React Query?" With built-in fetch caching, Server Actions, and the promise of a server-centric world, it felt like our trusted tools might be headed for retirement.

I spent weeks building with the new paradigm, trying to go "all-in" on the native features. And while they're incredibly powerful, I quickly ran into the familiar complexities of managing modern user interfaces: How do I refetch data when a user focuses on the browser tab? How do I handle complex mutations with loading and error states without a page refresh? What about optimistic updates for that snappy, instantaneous feel?

That's when it clicked. The question wasn't "Server Components or TanStack Query?" The real answer, the one that unlocks a truly world-class developer and user experience, is "Server Components and TanStack Query." They aren't competitors; they're partners.

The Modern Next.js Data Dilemma

The Next.js 15 App Router is a masterpiece of engineering. React Server Components (RSCs) allow us to fetch data on the server, render it to HTML, and ship a near-instant, non-interactive page. This is a monumental win for performance. But what happens the moment a user needs to interact with that data? What happens when they add an item to a list, update their profile, or navigate to a new page with paginated data?

This is the modern data dilemma. Native Next.js features give us an incredible starting point, but they don't provide a comprehensive, client-side framework for managing the lifecycle of that data once it's in the browser.

What You'll Learn: From Zero to Hero

This isn't just another setup guide. This is the comprehensive, production-ready playbook I wish I had when I started. By the end of this article, you will be an expert in leveraging TanStack Query (the library formerly known as React Query) within the Next.js 15 App Router.

You will master:

  • The foundational setup that avoids common pitfalls.
  • The critical RSC-to-Client hydration pattern for seamless server-to-client state handoff.
  • Building modern, robust mutations using the powerful combination of useMutation and Next.js Server Actions.
  • Implementing advanced techniques like optimistic updates for a superior UX.
  • Integrating TanStack Query with cutting-edge Next.js 15 features like Partial Prerendering (PPR) and understanding its synergy with the new React Compiler.

So grab your editor, and let's build the ultimate data layer for your Next.js 15 application.


Why TanStack Query is Still Your Superpower in Next.js 15

Before we dive into the code, it's crucial to understand the why. Why add another dependency when Next.js gives us so much out of the box? The answer lies in a clear separation of concerns.

Server Components vs. Client State Management: A Clear Distinction

Think of it this way:

  • React Server Components (RSCs) are for the initial render. Their job is to fetch data once, render the initial HTML on the server, and send it to the browser. They are "render-and-forget." They excel at getting that first paint on the screen as fast as possible.
  • TanStack Query is for the data lifecycle on the client. Once the data arrives in the browser, TanStack Query takes over. It's a client-side state manager specifically for asynchronous server state. It answers questions like: Is this data fresh or stale? Should I refetch it in the background? What should the UI show while a mutation is in progress?

They solve different problems. Using them together gives you the best of both worlds: lightning-fast initial loads from the server and a rich, interactive experience on the client.

The Core Benefits You Can't Get from fetch Alone

If you're still on the fence, here's a list of TanStack Query's superpowers that native fetch and cache in Next.js don't provide on the client side. This is the kind of functionality that separates a good app from a great one.

  • Automatic Caching & Garbage Collection: TanStack Query intelligently caches data and automatically garbage collects it when it's no longer used, preventing memory bloat.
  • Background Refetching: It can automatically refetch stale data in the background when a user re-focuses a window, reconnects to the network, or you re-mount a component. This keeps your UI feeling fresh and up-to-date without manual intervention.
  • Declarative Loading & Error States: useQuery provides simple booleans like isLoading, isError, isFetching, and isSuccess that make managing complex UI states trivial. No more manual useState flags for loading states.
  • Effortless Mutations: The useMutation hook simplifies data updates (POST, PUT, DELETE), providing the same declarative states (isPending, isError) and powerful lifecycle callbacks.
  • Powerful Devtools: The React Query Devtools are a game-changer for debugging, letting you visually inspect your cache, see query states, and even manually trigger actions.
  • Advanced Features: It comes with built-in, battle-tested solutions for complex patterns like pagination, infinite scrolling, and optimistic updates.

Comparison: TanStack Query vs. SWR vs. Native Next.js cache

To help you decide, here's a quick comparison of the popular options for data fetching in Next.js:

FeatureTanStack Query (useQuery)SWR (useSWR)Native Next.js cache / fetch
Primary Use CaseClient-side async state management & cachingClient-side async state management & cachingServer-side data fetching & caching for RSCs
Mutation HelpersExcellent (useMutation, optimistic updates, callbacks)Good (useSWRMutation)Manual (Server Actions + form states)
DevtoolsBest-in-class, highly detailed visualizerLimited browser extensionNone
Cache InvalidationGranular control via queryClient.invalidateQueriesGood control via mutate functionTag-based or path-based revalidation (revalidateTag, revalidatePath)
Framework AgnosticYes (React, Solid, Vue, Svelte)No (React-specific)No (Next.js-specific)
Best For...Complex apps needing robust mutation handling, optimistic updates, and deep insight into the cache.Simpler apps or teams already familiar with the Vercel ecosystem.The initial data load within Server Components.

My take: For any application with a non-trivial amount of client-side interactivity and data mutations, TanStack Query's robust feature set, especially its mutation handling and devtools, provides an unparalleled developer experience and a more resilient application.


Part 1: The Foundational Setup for TanStack Query in the Next.js App Directory

Getting the initial setup right is critical to avoid headaches down the road. The key principle is to create a single QueryClient instance that persists across requests on the server and is provided to all Client Components in the browser.

Step 1: Installing TanStack Query

First, let's get the necessary packages. Open your terminal in your Next.js 15 project and run:

bash
npm install @tanstack/react-query @tanstack/react-query-devtools
# or
yarn add @tanstack/react-query @tanstack/react-query-devtools
# or
pnpm add @tanstack/react-query @tanstack/react-query-devtools

Step 2: Creating the QueryClient Singleton

In a server-centric framework like Next.js, it's easy to accidentally create a new QueryClient on every render, which leads to memory leaks and inconsistent caches. The solution is a singleton pattern.

Create a new file at lib/query-client.ts:

typescript
// lib/query-client.ts
import { QueryClient } from '@tanstack/react-query'
import { cache } from 'react'

// cache() is a React function that lets us cache a function's result.
// Here, we're using it to ensure that we're dealing with a single instance
// of QueryClient per request.
const getQueryClient = cache(() => new QueryClient())
export default getQueryClient

Pro Tip: Using cache from React is the modern, recommended way to handle request-scoped singletons in the App Router. It ensures that during a single server rendering pass, every component that asks for the QueryClient gets the exact same instance, but different server requests get their own separate instances. This prevents data from leaking between users.

Step 3: The Essential QueryClientProvider Setup in layout.tsx

TanStack Query works by providing the client instance to your component tree via a context provider. Since this is a client-side feature, we need to create a boundary. The best practice is to create a dedicated Providers component.

Create a new file at app/providers.tsx:

typescript
// app/providers.tsx
'use client'

import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import { useState } from 'react'

export default function Providers({ children }: { children: React.ReactNode }) {
  // We use useState to create the client instance on the client side.
  // This ensures that the client is not recreated on every render.
  const [queryClient] = useState(
    () =>
      new QueryClient({
        defaultOptions: {
          queries: {
            // With SSR, we usually want to set some default staleTime
            // above 0 to avoid refetching immediately on the client
            staleTime: 60 * 1000,
          },
        },
      }),
  )

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

Now, wrap your root layout with this new Providers component.

Open app/layout.tsx and modify it:

tsx
// app/layout.tsx
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'
import Providers from './providers' // Import the new Providers component

const inter = Inter({ subsets: ['latin'] })

export const metadata: Metadata = {
  title: 'Create Next App',
  description: 'Generated by create next app',
}

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode
}>) {
  return (
    <html lang="en">
      <body className={inter.className}>
        <Providers>{children}</Providers> {/* Wrap children with Providers */}
      </body>
    </html>
  )
}

Step 4: Integrating the React Query Devtools

Notice we already added <ReactQueryDevtools /> in our providers.tsx file. That's all it takes! By default, they only bundle in development mode, so you don't have to worry about them bloating your production build.

Now, when you run your app in development mode (npm run dev), you'll see a small React Query logo in the bottom corner of your browser. Click it, and you'll have access to a powerful inspector that shows you all active queries, their states, and the cached data. This is invaluable for debugging.

Important Note on staleTime: Notice we set a default staleTime: 60 * 1000 (60 seconds) in our provider. This is crucial for SSR. Without it, TanStack Query would consider data stale immediately and refetch it on the client, even though we just rendered it on the server. A sensible staleTime prevents this unnecessary double-fetch. You can override this on a per-query basis as needed.


Part 2: The Critical RSC-to-Client Hydration Pattern

This is where the magic happens. This pattern is the bridge between your Server Components and Client Components, allowing you to prefetch data on the server and seamlessly hand it off to TanStack Query on the client.

The Problem: Avoiding Waterfall Requests

Imagine this scenario: You have a Server Component that renders the page shell, and inside it, you have a Client Component that uses useQuery to fetch data. Without proper hydration, here's what happens:

  1. Server renders the page shell
  2. Browser receives HTML
  3. Client Component mounts
  4. useQuery fires its request
  5. User sees a loading spinner

This creates a request waterfall and a poor user experience. The user stares at a loading state even though the server could have fetched that data during the initial render.

The Solution: Prefetch on Server, Hydrate on Client

The pattern is simple but powerful:

  1. Fetch data in a Server Component (RSC)
  2. Prefill the TanStack Query cache with that data
  3. Pass the data to a Client Component that uses the same query
  4. TanStack Query recognizes it already has the data and renders immediately

Let's build this step by step.

Step 1: Create a Server-Side Query Helper

Create a new file at lib/server-queries.ts:

typescript
// lib/server-queries.ts
import { QueryClient } from '@tanstack/react-query'
import getQueryClient from './query-client'

// Example: Fetch posts from an API
export async function getPosts() {
  const res = await fetch('https://jsonplaceholder.typicode.com/posts', {
    next: { revalidate: 60 }, // Use Next.js caching
  })
  if (!res.ok) throw new Error('Failed to fetch posts')
  return res.json()
}

// Prefetch function for Server Components
export async function prefetchPosts() {
  const queryClient = getQueryClient()
  
  await queryClient.prefetchQuery({
    queryKey: ['posts'],
    queryFn: getPosts,
  })
  
  return queryClient
}

Step 2: Use It in a Server Component

Create a new page at app/posts/page.tsx:

tsx
// app/posts/page.tsx
import { HydrationBoundary, dehydrate } from '@tanstack/react-query'
import { prefetchPosts } from '@/lib/server-queries'
import PostsList from './posts-list'

export default async function PostsPage() {
  const queryClient = await prefetchPosts()
  
  return (
    <div className="container mx-auto p-8">
      <h1 className="text-3xl font-bold mb-8">Posts</h1>
      {/* HydrationBoundary passes the prefetched data to Client Components */}
      <HydrationBoundary state={dehydrate(queryClient)}>
        <PostsList />
      </HydrationBoundary>
    </div>
  )
}

Step 3: Create the Client Component with useQuery

Create app/posts/posts-list.tsx:

tsx
// app/posts/posts-list.tsx
'use client'

import { useQuery } from '@tanstack/react-query'
import { getPosts } from '@/lib/server-queries'

interface Post {
  userId: number
  id: number
  title: string
  body: string
}

export default function PostsList() {
  const { data, isLoading, isError } = useQuery<Post[]>({
    queryKey: ['posts'],
    queryFn: getPosts,
    staleTime: 60 * 1000,
  })

  if (isLoading) return <div>Loading...</div>
  if (isError) return <div>Error loading posts</div>

  return (
    <div className="grid gap-4">
      {data?.map((post) => (
        <div key={post.id} className="border rounded-lg p-4">
          <h2 className="text-xl font-semibold mb-2">{post.title}</h2>
          <p className="text-gray-600">{post.body}</p>
        </div>
      ))}
    </div>
  )
}

What Just Happened?

Let's break down the flow:

  1. The Server Component (PostsPage) calls prefetchPosts(), which fetches the data and stores it in the QueryClient
  2. We dehydrate the query client to serialize its cache state
  3. HydrationBoundary receives this dehydrated state and provides it to all child components
  4. When PostsList mounts on the client, its useQuery hook checks the cache
  5. The data is already there! No loading state, no refetch, instant render
  6. TanStack Query still manages this data—if the user navigates away and back, or if it becomes stale, it will refetch in the background

This is the ideal pattern. You get the performance of server-side rendering with the power of client-side state management.


Part 3: Mastering Mutations with Server Actions

Mutations are where TanStack Query truly shines. The useMutation hook provides a clean, declarative way to handle data updates with comprehensive loading, error, and success states.

The Modern Approach: useMutation + Server Actions

Next.js 15 Server Actions are perfect for mutations. They run on the server, have direct access to your database or API, and can be called directly from Client Components. Pair them with useMutation, and you have an unstoppable combination.

Step 1: Create a Server Action

Create app/actions.ts:

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

import { revalidatePath } from 'next/cache'

export async function createPost(data: { title: string; body: string }) {
  // Simulate API call
  const res = await fetch('https://jsonplaceholder.typicode.com/posts', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(data),
  })
  
  if (!res.ok) {
    throw new Error('Failed to create post')
  }
  
  const newPost = await res.json()
  
  // Optionally revalidate the posts page
  revalidatePath('/posts')
  
  return newPost
}

Step 2: Use useMutation in a Client Component

Create app/posts/create-post-form.tsx:

tsx
// app/posts/create-post-form.tsx
'use client'

import { useMutation, useQueryClient } from '@tanstack/react-query'
import { createPost } from '@/app/actions'
import { useState } from 'react'

export default function CreatePostForm() {
  const [title, setTitle] = useState('')
  const [body, setBody] = useState('')
  const queryClient = useQueryClient()

  const mutation = useMutation({
    mutationFn: createPost,
    onSuccess: () => {
      // Invalidate and refetch posts query
      queryClient.invalidateQueries({ queryKey: ['posts'] })
      // Reset form
      setTitle('')
      setBody('')
    },
  })

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault()
    mutation.mutate({ title, body })
  }

  return (
    <form onSubmit={handleSubmit} className="space-y-4 mb-8">
      <input
        type="text"
        value={title}
        onChange={(e) => setTitle(e.target.value)}
        placeholder="Post title"
        className="w-full px-4 py-2 border rounded"
        disabled={mutation.isPending}
      />
      <textarea
        value={body}
        onChange={(e) => setBody(e.target.value)}
        placeholder="Post body"
        className="w-full px-4 py-2 border rounded"
        rows={4}
        disabled={mutation.isPending}
      />
      <button
        type="submit"
        disabled={mutation.isPending}
        className="px-6 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:bg-gray-400"
      >
        {mutation.isPending ? 'Creating...' : 'Create Post'}
      </button>
      
      {mutation.isError && (
        <p className="text-red-600">Error: {mutation.error.message}</p>
      )}
      {mutation.isSuccess && (
        <p className="text-green-600">Post created successfully!</p>
      )}
    </form>
  )
}

Understanding the Mutation Lifecycle

The useMutation hook gives you everything you need:

  • mutation.mutate(data): Triggers the mutation
  • mutation.isPending: true while the mutation is in progress
  • mutation.isError: true if the mutation failed
  • mutation.isSuccess: true if the mutation succeeded
  • mutation.error: The error object if failed
  • mutation.data: The returned data if successful

The onSuccess callback is where you typically:

  1. Invalidate related queries to trigger a refetch
  2. Update the UI or show a success message
  3. Navigate to a different page
  4. Reset form state

Part 4: Advanced Patterns - Optimistic Updates

Optimistic updates are the secret sauce for making your app feel instantaneous. Instead of waiting for the server to respond, you immediately update the UI with the expected result, then reconcile with the actual server response later.

Implementing Optimistic Updates

Here's a complete example of optimistic updates for deleting a post:

tsx
// app/posts/delete-post-button.tsx
'use client'

import { useMutation, useQueryClient } from '@tanstack/react-query'
import { deletePost } from '@/app/actions'

interface Post {
  id: number
  title: string
  body: string
}

export default function DeletePostButton({ postId }: { postId: number }) {
  const queryClient = useQueryClient()

  const mutation = useMutation({
    mutationFn: deletePost,
    
    // Optimistically update the cache before the server responds
    onMutate: async (deletedId) => {
      // Cancel any outgoing refetches to avoid overwriting our optimistic update
      await queryClient.cancelQueries({ queryKey: ['posts'] })

      // Snapshot the previous value
      const previousPosts = queryClient.getQueryData<Post[]>(['posts'])

      // Optimistically update the cache
      queryClient.setQueryData<Post[]>(['posts'], (old) =>
        old?.filter((post) => post.id !== deletedId)
      )

      // Return context with the previous value
      return { previousPosts }
    },
    
    // If the mutation fails, roll back to the previous value
    onError: (err, deletedId, context) => {
      queryClient.setQueryData(['posts'], context?.previousPosts)
    },
    
    // Always refetch after error or success to ensure consistency
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['posts'] })
    },
  })

  return (
    <button
      onClick={() => mutation.mutate(postId)}
      disabled={mutation.isPending}
      className="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
    >
      {mutation.isPending ? 'Deleting...' : 'Delete'}
    </button>
  )
}

This pattern ensures:

  1. Instant UI feedback - The post disappears immediately
  2. Automatic rollback - If the server request fails, the UI reverts
  3. Data consistency - A final refetch ensures the UI matches the server

Part 5: Next.js 15 Integration - PPR and React Compiler

Partial Prerendering (PPR)

TanStack Query works beautifully with Next.js 15's experimental Partial Prerendering. With PPR, you can:

  1. Prerender the static shell with <Suspense> boundaries
  2. Stream in dynamic content
  3. Use TanStack Query for client-side interactivity on that dynamic content

Example with PPR:

tsx
// app/dashboard/page.tsx
import { Suspense } from 'react'
import { HydrationBoundary, dehydrate } from '@tanstack/react-query'
import { prefetchUserData } from '@/lib/server-queries'
import UserProfile from './user-profile'

export const experimental_ppr = true

export default async function Dashboard() {
  return (
    <div>
      <h1>Dashboard</h1>
      
      {/* Static header prerenders */}
      <header className="static-shell">Welcome back!</header>
      
      {/* Dynamic content streams in */}
      <Suspense fallback={<div>Loading profile...</div>}>
        <UserProfileWrapper />
      </Suspense>
    </div>
  )
}

async function UserProfileWrapper() {
  const queryClient = await prefetchUserData()
  
  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <UserProfile />
    </HydrationBoundary>
  )
}

React Compiler Compatibility

The React Compiler (formerly React Forget) automatically optimizes your components. TanStack Query v5 is fully compatible. The hooks are already optimized and won't need manual useMemo or useCallback wrapping when the compiler is enabled.


Conclusion: Your Path Forward

You now have a complete, production-ready setup for TanStack Query in Next.js 15. Here's what you've mastered:

Key Takeaways

  • Foundational Setup - Singleton QueryClient and proper provider configuration
  • RSC Hydration - Seamless server-to-client data handoff with zero waterfalls
  • Mutations - Robust data updates with Server Actions and useMutation
  • Optimistic Updates - Instant UI feedback with automatic rollback
  • Modern Features - Integration with PPR and React Compiler

Best Practices Checklist

  • ✅ Always use the singleton pattern for QueryClient on the server
  • ✅ Set a sensible default staleTime (60s+) to avoid double-fetching
  • ✅ Use HydrationBoundary to pass server-fetched data to client
  • ✅ Keep queryKey consistent between server and client
  • ✅ Invalidate queries after mutations with queryClient.invalidateQueries()
  • ✅ Leverage the React Query Devtools for debugging
  • ✅ Implement optimistic updates for delete/update operations

Next Steps

To take your skills further:

  1. Explore Infinite Queries - useInfiniteQuery for infinite scroll patterns
  2. Dive into Advanced Caching - Learn about cache time, garbage collection, and custom cache strategies
  3. Master Dependent Queries - Chain queries where one depends on data from another
  4. Study the Devtools - Become proficient at debugging cache states and network requests

The combination of Next.js 15 and TanStack Query isn't just powerful—it's transformative. You're now equipped to build data-driven applications that are both blazingly fast and delightfully interactive.

Now go build something amazing. 🚀


Want to dive deeper? Check out the TanStack Query docs and the Next.js 15 documentation for even more advanced patterns and techniques.

next.js 15tanstack queryreact queryserver componentsdata fetchingstate managementapp routertutorialcachingjavascripttypescript
Written by
Daniel Olawoyin

Full-stack & AI engineer based in Lagos. I build production systems with AI in them — voice agents, RAG pipelines, multi-tenant SaaS.