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

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
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 useMutationand 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
- 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: useQueryprovides simple booleans likeisLoading,isError,isFetching, andisSuccessthat make managing complex UI states trivial. No more manualuseStateflags for loading states.
- Effortless Mutations: The useMutationhook 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:
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
Step 1: Installing TanStack Query
First, let's get the necessary packages. Open your terminal in your Next.js 15 project and run:
bashnpm 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
Create a new file at
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
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
Create a new file at
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
Open
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
Now, when you run your app in development mode (
Important Note on
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
- Server renders the page shell
- Browser receives HTML
- Client Component mounts
- useQueryfires its request
- 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:
- Fetch data in a Server Component (RSC)
- Prefill the TanStack Query cache with that data
- Pass the data to a Client Component that uses the same query
- 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
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
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
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:
- The Server Component (PostsPage) callsprefetchPosts(), which fetches the data and stores it in theQueryClient
- We dehydratethe query client to serialize its cache state
- HydrationBoundaryreceives this dehydrated state and provides it to all child components
- When PostsListmounts on the client, itsuseQueryhook checks the cache
- The data is already there! No loading state, no refetch, instant render
- 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
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
Step 1: Create a Server Action
Create
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
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
- mutation.mutate(data): Triggers the mutation
- mutation.isPending:truewhile the mutation is in progress
- mutation.isError:trueif the mutation failed
- mutation.isSuccess:trueif the mutation succeeded
- mutation.error: The error object if failed
- mutation.data: The returned data if successful
The
- Invalidate related queries to trigger a refetch
- Update the UI or show a success message
- Navigate to a different page
- 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:
- Instant UI feedback - The post disappears immediately
- Automatic rollback - If the server request fails, the UI reverts
- 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:
- Prerender the static shell with <Suspense>boundaries
- Stream in dynamic content
- 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
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 QueryClienton the server
- ✅ Set a sensible default staleTime(60s+) to avoid double-fetching
- ✅ Use HydrationBoundaryto pass server-fetched data to client
- ✅ Keep queryKeyconsistent 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:
- Explore Infinite Queries - useInfiniteQueryfor infinite scroll patterns
- Dive into Advanced Caching - Learn about cache time, garbage collection, and custom cache strategies
- Master Dependent Queries - Chain queries where one depends on data from another
- 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.

Daniel Olawoyin
Full-stack engineer — Lagos