Mastering Data Fetching in Next.js with TanStack Query
TL;DR: If your Next.js app fetches data that changes — dashboards, feeds, forms, anything interactive — TanStack Query (v5) deletes most of your data-fetching boilerplate. This guide covers setup in App Router,
useQuerypatterns, mutations, optimistic updates, and server-side hydration, with complete runnable examples.
The problem with "just use fetch"
Next.js gives you plenty of ways to fetch data: fetch in Server Components, useEffect + useState on the client, route handlers, server actions. For a static marketing page, that's plenty.
But the moment your UI becomes interactive — filters, pagination, likes, live dashboards, offline tolerance — hand-rolled fetching collapses into the same pile of problems:
- Boilerplate everywhere: every fetch needs
isLoading,isError,data, and retry logic. - Stale data: users stare at yesterday's numbers until someone remembers to refetch.
- Race conditions: two in-flight requests resolve out of order and the UI shows the wrong one.
- No real cache: every mount re-fetches; deduplicating identical requests is DIY.
- Prop drilling: refetch functions get threaded through five layers of components.
- Optimistic UI: instant-feeling mutations with rollback are genuinely hard to get right by hand.
This is server state — data you don't own, that can change under you — and it deserves its own manager. That's TanStack Query.
What TanStack Query actually does for you
| Concern | Hand-rolled useEffect | TanStack Query |
|---|---|---|
| Loading / error states | Manual flags per component | Built into every hook |
| Caching | DIY (usually none) | Automatic, keyed, deduped |
| Background refetching | DIY | Stale-while-revalidate out of the box |
| Request deduplication | DIY | Automatic |
| Optimistic updates | DIY + rollback bugs | onMutate / onError / onSettled lifecycle |
| Devtools | console.log | First-class devtools panel |
| SSR hydration | DIY serialization | dehydrate / HydrationBoundary |
The mental model is simple: you declare what data a component needs via a query key, and the library handles how it gets fetched, cached, and kept fresh.
Setup in Next.js App Router
1. Install
npm install @tanstack/react-query @tanstack/react-query-devtools
2. Create the provider
The QueryClient must be created fresh per request on the server, but shared in the browser. The cleanest App Router pattern uses useState to lazily create one client per component lifetime:
// app/providers.tsx
"use client";
import { useState } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
export default function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
// Data is "fresh" for 60s — no refetch on remount within this window
staleTime: 60 * 1000,
// Don't refetch just because the user alt-tabbed back
refetchOnWindowFocus: false,
},
},
})
);
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
3. Wire it into the root layout
// app/layout.tsx
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import Providers from "./providers";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "Next.js + TanStack Query",
description: "Advanced data fetching patterns with TanStack Query v5",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className={inter.className}>
<Providers>{children}</Providers>
</body>
</html>
);
}
That's the entire setup. Everything below builds on it.
Basic data fetching with useQuery
// components/PostsList.tsx
"use client";
import { useQuery } from "@tanstack/react-query";
interface Post {
id: number;
title: string;
body: string;
}
async function fetchPosts(): Promise<Post[]> {
const res = await fetch("https://jsonplaceholder.typicode.com/posts");
if (!res.ok) throw new Error("Failed to fetch posts");
return res.json();
}
export default function PostsList() {
const { data, isLoading, isError, error } = useQuery({
queryKey: ["posts"],
queryFn: fetchPosts,
});
if (isLoading) return <p>Loading posts…</p>;
if (isError) return <p>Error: {error.message}</p>;
return (
<section>
<h2>Posts</h2>
<ul>
{data?.map((post) => (
<li key={post.id}>
<h3>{post.title}</h3>
<p>{post.body}</p>
</li>
))}
</ul>
</section>
);
}
No useState, no useEffect, no manual flags. Mount a second component with queryKey: ["posts"] and it reads from cache instantly — the library dedupes the network request and revalidates in the background.
Advanced useQuery patterns
Dependent queries
Fetch B only after A resolves, using enabled:
// components/UserDetails.tsx
"use client";
import { useQuery } from "@tanstack/react-query";
interface User {
id: number;
name: string;
email: string;
}
interface UserPost {
id: number;
title: string;
}
async function fetchUser(userId: number): Promise<User> {
const res = await fetch(
`https://jsonplaceholder.typicode.com/users/${userId}`
);
if (!res.ok) throw new Error("Failed to fetch user");
return res.json();
}
async function fetchUserPosts(userId: number): Promise<UserPost[]> {
const res = await fetch(
`https://jsonplaceholder.typicode.com/posts?userId=${userId}`
);
if (!res.ok) throw new Error("Failed to fetch user posts");
return res.json();
}
export default function UserDetails({ userId }: { userId: number }) {
const {
data: user,
isLoading: userLoading,
isError: userError,
} = useQuery({
queryKey: ["user", userId],
queryFn: () => fetchUser(userId),
});
const {
data: posts,
isLoading: postsLoading,
isError: postsError,
} = useQuery({
queryKey: ["user", userId, "posts"],
queryFn: () => fetchUserPosts(userId),
enabled: !!user, // waits for the user query to succeed
});
if (userLoading || postsLoading) return <p>Loading user details…</p>;
if (userError || postsError) return <p>Something went wrong.</p>;
return (
<section>
<h2>{user?.name}</h2>
<p>{user?.email}</p>
<h3>Posts by {user?.name}</h3>
<ul>
{posts?.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
</section>
);
}
Note the hierarchical key ["user", userId, "posts"] — invalidating ["user", userId] later cascades to it. Key design is cache design.
Parallel queries
Independent queries just run side by side — no orchestration needed:
// components/Dashboard.tsx
"use client";
import { useQuery } from "@tanstack/react-query";
async function fetchVisitors(): Promise<{ value: number }> {
const res = await fetch("/api/visitors");
if (!res.ok) throw new Error("Failed to fetch visitors");
return res.json();
}
async function fetchSales(): Promise<{ value: number }> {
const res = await fetch("/api/sales");
if (!res.ok) throw new Error("Failed to fetch sales");
return res.json();
}
export default function Dashboard() {
const { data: visitors, isLoading: visitorsLoading } = useQuery({
queryKey: ["visitors"],
queryFn: fetchVisitors,
});
const { data: sales, isLoading: salesLoading } = useQuery({
queryKey: ["sales"],
queryFn: fetchSales,
});
if (visitorsLoading || salesLoading) return <p>Loading dashboard…</p>;
return (
<section>
<h2>Dashboard</h2>
<p>Total visitors: {visitors?.value}</p>
<p>Total sales: {sales?.value}</p>
</section>
);
}
(For a dynamic number of parallel queries, reach for useQueries instead.)
Infinite scrolling with useInfiniteQuery
// components/InfinitePosts.tsx
"use client";
import { useInfiniteQuery } from "@tanstack/react-query";
interface Post {
id: number;
title: string;
body: string;
}
interface PostsPage {
posts: Post[];
nextCursor: number | undefined;
}
async function fetchPostsPage(pageParam: number): Promise<PostsPage> {
const limit = 10;
const res = await fetch(
`https://jsonplaceholder.typicode.com/posts?_start=${pageParam}&_limit=${limit}`
);
if (!res.ok) throw new Error("Failed to fetch posts");
const posts: Post[] = await res.json();
return {
posts,
nextCursor: posts.length === limit ? pageParam + limit : undefined,
};
}
export default function InfinitePosts() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
isError,
error,
} = useInfiniteQuery({
queryKey: ["infinitePosts"],
queryFn: ({ pageParam }) => fetchPostsPage(pageParam),
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
if (isLoading) return <p>Loading posts…</p>;
if (isError) return <p>Error: {error.message}</p>;
return (
<section>
<h2>Infinite posts</h2>
{data?.pages.map((page, i) => (
<div key={i}>
{page.posts.map((post) => (
<article key={post.id}>
<h3>{post.title}</h3>
<p>{post.body}</p>
</article>
))}
</div>
))}
<button
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
>
{isFetchingNextPage
? "Loading more…"
: hasNextPage
? "Load more"
: "Nothing more to load"}
</button>
</section>
);
}
useInfiniteQuery keeps every fetched page in the cache, so scrolling back up never re-fetches.
Mutations with useMutation
Queries read; mutations write. On success, invalidate the affected queries so the UI converges on the truth:
// components/CreatePostForm.tsx
"use client";
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
interface NewPost {
title: string;
body: string;
userId: number;
}
async function createPost(newPost: NewPost): Promise<NewPost & { id: number }> {
const res = await fetch("https://jsonplaceholder.typicode.com/posts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(newPost),
});
if (!res.ok) throw new Error("Failed to create post");
return res.json();
}
export default function CreatePostForm() {
const queryClient = useQueryClient();
const [title, setTitle] = useState("");
const [body, setBody] = useState("");
const mutation = useMutation({
mutationFn: createPost,
onSuccess: () => {
// Mark ["posts"] as stale — it refetches on next read
queryClient.invalidateQueries({ queryKey: ["posts"] });
setTitle("");
setBody("");
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
mutation.mutate({ title, body, userId: 1 });
};
return (
<form onSubmit={handleSubmit}>
<h2>Create post</h2>
<label>
Title
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
disabled={mutation.isPending}
required
/>
</label>
<label>
Body
<textarea
value={body}
onChange={(e) => setBody(e.target.value)}
disabled={mutation.isPending}
required
/>
</label>
<button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? "Creating…" : "Create post"}
</button>
{mutation.isError && <p>Error: {mutation.error.message}</p>}
</form>
);
}
Optimistic updates
Invalidation is correct but feels slow — the UI waits for a round trip. Optimistic updates flip it: update the cache immediately, roll back on failure, then reconcile with the server:
// components/TodoItem.tsx
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
interface Todo {
id: number;
title: string;
completed: boolean;
}
async function updateTodoStatus(todo: Todo): Promise<Todo> {
const res = await fetch(
`https://jsonplaceholder.typicode.com/todos/${todo.id}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(todo),
}
);
if (!res.ok) throw new Error("Failed to update todo");
return res.json();
}
export default function TodoItem({ todo }: { todo: Todo }) {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: updateTodoStatus,
// 1. Runs before the network request
onMutate: async (newTodo) => {
// Stop any in-flight refetches that could overwrite us
await queryClient.cancelQueries({ queryKey: ["todos"] });
// Snapshot for rollback
const previousTodos = queryClient.getQueryData<Todo[]>(["todos"]);
// Optimistically apply the change
queryClient.setQueryData<Todo[]>(["todos"], (old) =>
old ? old.map((t) => (t.id === newTodo.id ? newTodo : t)) : []
);
return { previousTodos };
},
// 2. Roll back if the request fails
onError: (_err, _newTodo, context) => {
queryClient.setQueryData(["todos"], context?.previousTodos);
},
// 3. Always reconcile with the server afterwards
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["todos"] });
},
});
return (
<div>
<span>{todo.title}</span>
<button
onClick={() => mutation.mutate({ ...todo, completed: !todo.completed })}
disabled={mutation.isPending}
>
{todo.completed ? "Mark incomplete" : "Mark complete"}
</button>
</div>
);
}
The onMutate → onError → onSettled trio is the whole pattern. Learn it once, reuse it for likes, toggles, cart updates, and drag-and-drop ordering.
Server Components + initial data (SSR hydration)
useQuery runs on the client, but you don't have to pay for an empty first paint. Prefetch on the server, dehydrate the cache, and hydrate it on the client:
// lib/getPosts.ts
export interface Post {
id: number;
title: string;
body: string;
}
export async function getPosts(): Promise<Post[]> {
const res = await fetch("https://jsonplaceholder.typicode.com/posts", {
// Opt out of Next's fetch cache — TanStack owns caching here
cache: "no-store",
});
if (!res.ok) throw new Error("Failed to fetch posts");
return res.json();
}
// app/page.tsx — a Server Component
import {
dehydrate,
HydrationBoundary,
QueryClient,
} from "@tanstack/react-query";
import { getPosts } from "@/lib/getPosts";
import PostsList from "@/components/PostsList";
export default async function HomePage() {
const queryClient = new QueryClient();
await queryClient.prefetchQuery({
queryKey: ["posts"],
queryFn: getPosts,
});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
{/* PostsList calls useQuery(["posts"]) and finds a warm cache — no client fetch */}
<PostsList />
</HydrationBoundary>
);
}
The result: server-rendered HTML on first paint, then the client cache takes over for instant navigations and background revalidation. Note cache: "no-store" — when TanStack Query owns the cache, letting Next's fetch cache sit in front of it causes double-caching confusion.
Best practices
1. Treat query keys as a schema
Keep them in one place so invalidation stays predictable:
// lib/queryKeys.ts
export const queryKeys = {
posts: ["posts"],
post: (id: number) => ["posts", id],
user: (id: number) => ["user", id],
userPosts: (id: number) => ["user", id, "posts"],
} as const;
2. Handle errors globally, not per-hook
import { QueryCache, MutationCache, QueryClient } from "@tanstack/react-query";
new QueryClient({
queryCache: new QueryCache({
onError: (error, query) => {
console.error(`Query failed [${query.queryHash}]:`, error);
// toast.error("Something went wrong") — one place, not fifty
},
}),
mutationCache: new MutationCache({
onError: (error) => {
console.error("Mutation failed:", error);
},
}),
});
3. Wrap queries in custom hooks
// hooks/usePosts.ts
import { useQuery } from "@tanstack/react-query";
import { getPosts } from "@/lib/getPosts";
import { queryKeys } from "@/lib/queryKeys";
export function usePosts() {
return useQuery({ queryKey: queryKeys.posts, queryFn: getPosts });
}
Components stay dumb; keys, fetchers, and options each live in one file.
4. Prefetch on intent, not just on mount
import Link from "next/link";
import { useQueryClient } from "@tanstack/react-query";
function PostLink({ postId }: { postId: number }) {
const queryClient = useQueryClient();
return (
<Link
href={`/posts/${postId}`}
onMouseEnter={() =>
queryClient.prefetchQuery({
queryKey: queryKeys.post(postId),
queryFn: () => getPostById(postId),
staleTime: 60 * 1000,
})
}
>
View post {postId}
</Link>
);
}
Hover-prefetching makes navigations feel instant for almost zero cost.
When you don't need TanStack Query
Honest caveat, because not everything is server state:
- Truly static content — a marketing page or docs site is better served by plain Server Components and static generation.
- One-shot fetches — a contact form that POSTs once and shows a toast doesn't need a cache.
- URL-driven state — search params and filters that belong in the URL belong in the URL.
If none of your data changes after load, TanStack Query is overhead. If it does, it's the highest-leverage dependency you'll add.
Key takeaways
- Server state ≠ client state.
useStateowns what the user typed; TanStack Query owns what the server said. - Query keys are your cache schema — hierarchical, centralized, deliberate.
- Default to invalidation, optimize with optimistic updates where latency is felt (toggles, likes, carts).
- Hydrate from Server Components for fast first paint without giving up client-side reactivity.
- Set
staleTimeintentionally — it's the single knob controlling how "live" your app feels versus how many requests it makes.
Master these five, and data fetching stops being the hard part of your Next.js apps.


