TanStack Query v5 with Next.js App Router: The Complete Data Fetching Guide

TanStack Query v5 is the gold standard for server state management in React. When combined with the Next.js App Router, it creates a powerful data layer that handles caching, background refetching, optimistic updates, and more — all with minimal boilerplate. In this tutorial, you will build a production-ready data layer from scratch.
What You'll Build
A BookShelf application — a book collection manager featuring:
- Server-side query prefetching with Next.js App Router
- Client-side caching and background refetching
- Optimistic mutations for instant UI updates
- Infinite scroll pagination
- Dependent queries and parallel data fetching
- Error boundaries and loading states
- Devtools integration for debugging
Prerequisites
Before starting, ensure you have:
- Node.js 20+ installed
- Basic knowledge of React and TypeScript
- Familiarity with Next.js App Router (layouts, pages, server components)
- A code editor (VS Code recommended)
- Basic understanding of REST APIs
Step 1: Project Setup
Create a new Next.js project with TypeScript:
npx create-next-app@latest bookshelf --typescript --tailwind --eslint --app --src-dir
cd bookshelfInstall TanStack Query v5 and related dependencies:
npm install @tanstack/react-query @tanstack/react-query-devtools
npm install zodYour project structure should look like this:
bookshelf/
├── src/
│ ├── app/
│ │ ├── layout.tsx
│ │ ├── page.tsx
│ │ └── books/
│ ├── lib/
│ │ ├── query-client.ts
│ │ └── api.ts
│ └── components/
│ └── providers.tsx
├── package.json
└── tsconfig.json
Step 2: Configure the Query Client
The Query Client is the core of TanStack Query. It manages all query caches, defaults, and background refetching behavior.
Create src/lib/query-client.ts:
import { QueryClient, defaultShouldDehydrateQuery, isServer } from "@tanstack/react-query";
function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
// Don't refetch on window focus in SSR
staleTime: 60 * 1000, // 1 minute
gcTime: 5 * 60 * 1000, // 5 minutes garbage collection
retry: 2,
refetchOnWindowFocus: false,
},
dehydrate: {
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();
} else {
// Browser: make a new query client if we don't already have one
if (!browserQueryClient) browserQueryClient = makeQueryClient();
return browserQueryClient;
}
}Why separate server and browser clients? On the server, each request should get a fresh QueryClient to prevent data leaking between users. On the browser, we reuse the same client so the cache persists across navigations.
Step 3: Create the Providers Component
TanStack Query needs a provider wrapping your application. Create src/components/providers.tsx:
"use client";
import { QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { getQueryClient } from "@/lib/query-client";
export function Providers({ children }: { children: React.ReactNode }) {
const queryClient = getQueryClient();
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}Update src/app/layout.tsx to use the provider:
import type { Metadata } from "next";
import { Providers } from "@/components/providers";
import "./globals.css";
export const metadata: Metadata = {
title: "BookShelf - TanStack Query Demo",
description: "A book collection manager built with TanStack Query v5",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}Step 4: Define Types and API Layer
Create type definitions and API functions. First, create src/lib/types.ts:
import { z } from "zod";
export const BookSchema = z.object({
id: z.string(),
title: z.string(),
author: z.string(),
coverUrl: z.string().url(),
description: z.string(),
genre: z.string(),
rating: z.number().min(0).max(5),
publishedYear: z.number(),
createdAt: z.string().datetime(),
});
export type Book = z.infer<typeof BookSchema>;
export const BooksResponseSchema = z.object({
books: z.array(BookSchema),
nextCursor: z.string().nullable(),
totalCount: z.number(),
});
export type BooksResponse = z.infer<typeof BooksResponseSchema>;Now create the API layer in src/lib/api.ts:
import type { Book, BooksResponse } from "./types";
const API_BASE = "/api";
export async function fetchBooks(params: {
cursor?: string;
limit?: number;
genre?: string;
search?: string;
}): Promise<BooksResponse> {
const searchParams = new URLSearchParams();
if (params.cursor) searchParams.set("cursor", params.cursor);
if (params.limit) searchParams.set("limit", String(params.limit));
if (params.genre) searchParams.set("genre", params.genre);
if (params.search) searchParams.set("search", params.search);
const res = await fetch(`${API_BASE}/books?${searchParams}`);
if (!res.ok) throw new Error("Failed to fetch books");
return res.json();
}
export async function fetchBook(id: string): Promise<Book> {
const res = await fetch(`${API_BASE}/books/${id}`);
if (!res.ok) throw new Error("Book not found");
return res.json();
}
export async function createBook(
data: Omit<Book, "id" | "createdAt">
): Promise<Book> {
const res = await fetch(`${API_BASE}/books`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok) throw new Error("Failed to create book");
return res.json();
}
export async function updateBook(
id: string,
data: Partial<Omit<Book, "id" | "createdAt">>
): Promise<Book> {
const res = await fetch(`${API_BASE}/books/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok) throw new Error("Failed to update book");
return res.json();
}
export async function deleteBook(id: string): Promise<void> {
const res = await fetch(`${API_BASE}/books/${id}`, {
method: "DELETE",
});
if (!res.ok) throw new Error("Failed to delete book");
}Step 5: Create Query Key Factories
One of the most important patterns in TanStack Query is organizing your query keys. Create src/lib/queries.ts:
import { queryOptions, infiniteQueryOptions } from "@tanstack/react-query";
import { fetchBooks, fetchBook } from "./api";
export const bookKeys = {
all: ["books"] as const,
lists: () => [...bookKeys.all, "list"] as const,
list: (filters: { genre?: string; search?: string }) =>
[...bookKeys.lists(), filters] as const,
details: () => [...bookKeys.all, "detail"] as const,
detail: (id: string) => [...bookKeys.details(), id] as const,
};
// Query options factory for a single book
export function bookQueryOptions(id: string) {
return queryOptions({
queryKey: bookKeys.detail(id),
queryFn: () => fetchBook(id),
staleTime: 5 * 60 * 1000, // 5 minutes
});
}
// Infinite query options for the book list
export function booksInfiniteQueryOptions(filters: {
genre?: string;
search?: string;
}) {
return infiniteQueryOptions({
queryKey: bookKeys.list(filters),
queryFn: ({ pageParam }) =>
fetchBooks({
cursor: pageParam,
limit: 12,
...filters,
}),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
}Query Key Factories are a pattern recommended by the TanStack team. They centralize all your query keys in one place, making cache invalidation predictable and type-safe. The hierarchical structure allows you to invalidate at any level — all books, all lists, or a specific detail.
Step 6: Server-Side Prefetching
This is where Next.js App Router and TanStack Query shine together. You can prefetch data on the server and pass it to the client without waterfalls.
Create src/app/books/page.tsx:
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
import { getQueryClient } from "@/lib/query-client";
import { booksInfiniteQueryOptions } from "@/lib/queries";
import { BookList } from "@/components/book-list";
interface BooksPageProps {
searchParams: Promise<{ genre?: string; search?: string }>;
}
export default async function BooksPage({ searchParams }: BooksPageProps) {
const params = await searchParams;
const queryClient = getQueryClient();
// Prefetch on the server — data is immediately available on the client
await queryClient.prefetchInfiniteQuery(
booksInfiniteQueryOptions({
genre: params.genre,
search: params.search,
})
);
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-8">My BookShelf</h1>
<BookList
initialGenre={params.genre}
initialSearch={params.search}
/>
</div>
</HydrationBoundary>
);
}For individual book pages, create src/app/books/[id]/page.tsx:
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
import { getQueryClient } from "@/lib/query-client";
import { bookQueryOptions } from "@/lib/queries";
import { BookDetail } from "@/components/book-detail";
interface BookPageProps {
params: Promise<{ id: string }>;
}
export default async function BookPage({ params }: BookPageProps) {
const { id } = await params;
const queryClient = getQueryClient();
await queryClient.prefetchQuery(bookQueryOptions(id));
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<BookDetail id={id} />
</HydrationBoundary>
);
}Step 7: Build the Book List with Infinite Scroll
Create src/components/book-list.tsx:
"use client";
import { useInfiniteQuery } from "@tanstack/react-query";
import { useInView } from "react-intersection-observer";
import { useEffect, useState } from "react";
import { booksInfiniteQueryOptions } from "@/lib/queries";
import { BookCard } from "./book-card";
interface BookListProps {
initialGenre?: string;
initialSearch?: string;
}
export function BookList({ initialGenre, initialSearch }: BookListProps) {
const [genre, setGenre] = useState(initialGenre);
const [search, setSearch] = useState(initialSearch ?? "");
const { ref, inView } = useInView();
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
isError,
error,
} = useInfiniteQuery(booksInfiniteQueryOptions({ genre, search }));
// Auto-fetch next page when scroll sentinel is visible
useEffect(() => {
if (inView && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);
const books = data?.pages.flatMap((page) => page.books) ?? [];
const totalCount = data?.pages[0]?.totalCount ?? 0;
if (isError) {
return (
<div className="text-red-500 p-4 rounded-lg bg-red-50">
Error loading books: {error.message}
</div>
);
}
return (
<div>
{/* Search and Filter Bar */}
<div className="flex gap-4 mb-6">
<input
type="text"
placeholder="Search books..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="flex-1 px-4 py-2 border rounded-lg"
/>
<select
value={genre ?? ""}
onChange={(e) => setGenre(e.target.value || undefined)}
className="px-4 py-2 border rounded-lg"
>
<option value="">All Genres</option>
<option value="fiction">Fiction</option>
<option value="non-fiction">Non-Fiction</option>
<option value="sci-fi">Sci-Fi</option>
<option value="biography">Biography</option>
</select>
</div>
{/* Results count */}
<p className="text-sm text-gray-500 mb-4">
Showing {books.length} of {totalCount} books
</p>
{/* Book Grid */}
{isLoading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="animate-pulse bg-gray-200 rounded-lg h-64" />
))}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{books.map((book) => (
<BookCard key={book.id} book={book} />
))}
</div>
)}
{/* Infinite scroll sentinel */}
<div ref={ref} className="h-10 mt-4">
{isFetchingNextPage && (
<p className="text-center text-gray-500">Loading more books...</p>
)}
</div>
</div>
);
}Step 8: Optimistic Mutations
Optimistic updates make your app feel instant by updating the UI before the server responds. Create src/hooks/use-book-mutations.ts:
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { createBook, updateBook, deleteBook } from "@/lib/api";
import { bookKeys } from "@/lib/queries";
import type { Book } from "@/lib/types";
export function useCreateBook() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createBook,
onSuccess: () => {
// Invalidate all book lists to refetch with the new book
queryClient.invalidateQueries({ queryKey: bookKeys.lists() });
},
});
}
export function useUpdateBook(id: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: Partial<Omit<Book, "id" | "createdAt">>) =>
updateBook(id, data),
// Optimistic update
onMutate: async (newData) => {
// Cancel any outgoing refetches
await queryClient.cancelQueries({ queryKey: bookKeys.detail(id) });
// Snapshot the previous value
const previousBook = queryClient.getQueryData(bookKeys.detail(id));
// Optimistically update the cache
queryClient.setQueryData(bookKeys.detail(id), (old: Book | undefined) =>
old ? { ...old, ...newData } : old
);
return { previousBook };
},
// If the mutation fails, roll back to the previous value
onError: (_err, _newData, context) => {
if (context?.previousBook) {
queryClient.setQueryData(bookKeys.detail(id), context.previousBook);
}
},
// Always refetch after error or success
onSettled: () => {
queryClient.invalidateQueries({ queryKey: bookKeys.detail(id) });
queryClient.invalidateQueries({ queryKey: bookKeys.lists() });
},
});
}
export function useDeleteBook() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: deleteBook,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: bookKeys.lists() });
},
});
}The optimistic update pattern follows three steps: (1) cancel outgoing queries, (2) snapshot the old data and apply the update immediately, (3) roll back on error. This gives users instant feedback while keeping data consistent.
Step 9: Dependent and Parallel Queries
Sometimes you need to fetch data that depends on other data. Create src/hooks/use-book-with-reviews.ts:
"use client";
import { useQuery, useQueries } from "@tanstack/react-query";
import { bookQueryOptions } from "@/lib/queries";
async function fetchReviews(bookId: string) {
const res = await fetch(`/api/books/${bookId}/reviews`);
if (!res.ok) throw new Error("Failed to fetch reviews");
return res.json();
}
async function fetchRelatedBooks(genre: string) {
const res = await fetch(`/api/books?genre=${genre}&limit=4`);
if (!res.ok) throw new Error("Failed to fetch related books");
return res.json();
}
// Dependent query: reviews load after book data is available
export function useBookWithReviews(bookId: string) {
const bookQuery = useQuery(bookQueryOptions(bookId));
const reviewsQuery = useQuery({
queryKey: ["books", bookId, "reviews"],
queryFn: () => fetchReviews(bookId),
// Only fetch reviews once the book data is available
enabled: !!bookQuery.data,
});
return { bookQuery, reviewsQuery };
}
// Parallel queries: fetch multiple independent resources at once
export function useBookPageData(bookId: string) {
const results = useQueries({
queries: [
bookQueryOptions(bookId),
{
queryKey: ["books", bookId, "reviews"],
queryFn: () => fetchReviews(bookId),
},
],
});
return {
book: results[0],
reviews: results[1],
};
}Step 10: Search with Debounced Queries
For search functionality, you want to debounce API calls. Create src/hooks/use-debounced-search.ts:
"use client";
import { useQuery } from "@tanstack/react-query";
import { useState, useDeferredValue } from "react";
import { fetchBooks } from "@/lib/api";
export function useDebouncedBookSearch() {
const [search, setSearch] = useState("");
const deferredSearch = useDeferredValue(search);
const query = useQuery({
queryKey: ["books", "search", deferredSearch],
queryFn: () => fetchBooks({ search: deferredSearch, limit: 10 }),
enabled: deferredSearch.length >= 2,
placeholderData: (previousData) => previousData, // Keep old data visible
});
return {
search,
setSearch,
isStale: search !== deferredSearch,
...query,
};
}This uses React 19's useDeferredValue instead of a manual debounce timer. The placeholderData option keeps previous results visible while loading, preventing layout shifts.
Step 11: Error Handling and Retry Strategies
Create a robust error boundary. Create src/components/query-error-boundary.tsx:
"use client";
import { QueryErrorResetBoundary } from "@tanstack/react-query";
import { ErrorBoundary } from "react-error-boundary";
interface QueryErrorBoundaryProps {
children: React.ReactNode;
}
export function QueryErrorBoundary({ children }: QueryErrorBoundaryProps) {
return (
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary
onReset={reset}
fallbackRender={({ resetErrorBoundary, error }) => (
<div className="p-6 rounded-lg bg-red-50 border border-red-200">
<h3 className="text-lg font-semibold text-red-800 mb-2">
Something went wrong
</h3>
<p className="text-red-600 mb-4">{error.message}</p>
<button
onClick={resetErrorBoundary}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
>
Try again
</button>
</div>
)}
>
{children}
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
);
}You can customize retry behavior per-query:
useQuery({
queryKey: ["critical-data"],
queryFn: fetchCriticalData,
retry: (failureCount, error) => {
// Don't retry on 404s
if (error instanceof Error && error.message.includes("not found")) {
return false;
}
// Retry up to 3 times for other errors
return failureCount < 3;
},
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
});Step 12: Query Invalidation Patterns
Understanding when and how to invalidate queries is critical. Here are the key patterns:
const queryClient = useQueryClient();
// 1. Invalidate everything
queryClient.invalidateQueries();
// 2. Invalidate all books (lists + details)
queryClient.invalidateQueries({ queryKey: bookKeys.all });
// 3. Invalidate only book lists
queryClient.invalidateQueries({ queryKey: bookKeys.lists() });
// 4. Invalidate a specific list with filters
queryClient.invalidateQueries({
queryKey: bookKeys.list({ genre: "fiction" }),
});
// 5. Invalidate a single book detail
queryClient.invalidateQueries({ queryKey: bookKeys.detail("book-123") });
// 6. Remove a query from cache entirely
queryClient.removeQueries({ queryKey: bookKeys.detail("deleted-book") });
// 7. Prefetch data for a likely navigation
queryClient.prefetchQuery(bookQueryOptions("likely-next-book-id"));Pro tip: Use invalidateQueries for most cases — it marks data as stale and refetches in the background. Use removeQueries only when the data should no longer exist (like after deleting a resource). Use setQueryData for optimistic updates where you know the new value.
Step 13: API Route Handlers
Create the API routes to power your application. Create src/app/api/books/route.ts:
import { NextRequest, NextResponse } from "next/server";
// In-memory store for demo purposes
const books = new Map<string, any>();
// Seed some data
const genres = ["fiction", "non-fiction", "sci-fi", "biography"];
for (let i = 1; i <= 50; i++) {
const id = `book-${i}`;
books.set(id, {
id,
title: `Book Title ${i}`,
author: `Author ${i}`,
coverUrl: `https://picsum.photos/seed/${id}/300/400`,
description: `A fascinating book about topic ${i}.`,
genre: genres[i % genres.length],
rating: Math.round((Math.random() * 4 + 1) * 10) / 10,
publishedYear: 2020 + (i % 7),
createdAt: new Date().toISOString(),
});
}
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const cursor = searchParams.get("cursor");
const limit = parseInt(searchParams.get("limit") ?? "12");
const genre = searchParams.get("genre");
const search = searchParams.get("search");
let allBooks = Array.from(books.values());
// Filter by genre
if (genre) {
allBooks = allBooks.filter((b) => b.genre === genre);
}
// Filter by search
if (search) {
const lowerSearch = search.toLowerCase();
allBooks = allBooks.filter(
(b) =>
b.title.toLowerCase().includes(lowerSearch) ||
b.author.toLowerCase().includes(lowerSearch)
);
}
// Pagination
const startIndex = cursor
? allBooks.findIndex((b) => b.id === cursor) + 1
: 0;
const paginated = allBooks.slice(startIndex, startIndex + limit);
const nextCursor =
startIndex + limit < allBooks.length
? allBooks[startIndex + limit - 1]?.id
: null;
return NextResponse.json({
books: paginated,
nextCursor,
totalCount: allBooks.length,
});
}
export async function POST(request: NextRequest) {
const data = await request.json();
const id = `book-${Date.now()}`;
const book = {
...data,
id,
createdAt: new Date().toISOString(),
};
books.set(id, book);
return NextResponse.json(book, { status: 201 });
}Testing Your Implementation
Run the development server:
npm run devOpen the TanStack Query Devtools (the floating icon in the bottom-right corner) to inspect:
- Active queries — See all current queries and their state
- Cache entries — Inspect cached data
- Query timeline — Watch fetch/refetch patterns
- Stale/fresh indicators — Understand your staleTime settings
Test the following scenarios:
- Navigate between pages — Notice how data is cached and instantly available on return
- Filter by genre — Watch new queries fire while cached results stay available
- Edit a book — See the optimistic update happen instantly, then confirm with the server
- Go offline — Cached data remains available, mutations queue until reconnection
Troubleshooting
Common issues and solutions:
Hydration mismatch errors
Make sure your getQueryClient() returns the same client on the browser but a new one on the server. The pattern in Step 2 handles this correctly.
Queries refetching too often
Increase staleTime in your query options. A value of 0 (the default) means data is immediately stale and will refetch on every component mount.
Infinite scroll not working
Ensure getNextPageParam returns undefined (not null) when there are no more pages. TanStack Query checks for undefined specifically.
Data not updating after mutation
Make sure your invalidateQueries call uses the correct query key scope. Use the query key factory pattern to keep keys consistent.
Next Steps
Now that you have a solid foundation with TanStack Query v5 and Next.js:
- Add Suspense support — Use
useSuspenseQueryfor a more declarative loading pattern - Implement offline support — Use
@tanstack/query-persist-client-coreto persist cache to localStorage - Add WebSocket subscriptions — Combine TanStack Query with real-time data sources
- Explore query cancellation — Use
AbortSignalto cancel in-flight requests - Build a mutation queue — Handle offline mutations that sync when connection returns
Conclusion
TanStack Query v5 transforms how you handle server state in Next.js applications. By combining server-side prefetching with client-side caching, you get the best of both worlds: fast initial loads and instant subsequent navigations. The query key factory pattern keeps your cache invalidation predictable, while optimistic mutations make your UI feel responsive.
The key takeaways:
- Separate server and browser query clients to prevent data leaks
- Use HydrationBoundary to transfer server-prefetched data to the client
- Organize query keys with factory functions for predictable invalidation
- Implement optimistic updates for mutations that need instant feedback
- Leverage infinite queries for paginated lists with automatic loading
TanStack Query is not just a data fetching library — it is a complete server state management solution that reduces boilerplate, prevents bugs, and makes your app significantly faster.
Discuss Your Project with Us
We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.
Let's find the best solutions for your needs.
Related Articles

Authenticate Your Next.js 15 App with Auth.js v5: Email, OAuth, and Role-Based Access
Learn how to add production-ready authentication to your Next.js 15 application using Auth.js v5. This comprehensive guide covers Google OAuth, email/password credentials, protected routes, middleware, and role-based access control.

Build a Local AI Chatbot with Ollama and Next.js: Complete Guide
Build a private, fully local AI chatbot using Ollama and Next.js. This hands-on tutorial covers installation, streaming responses, model selection, and deploying a production-ready chat interface — all without sending data to the cloud.

Building a Content-Driven Website with Payload CMS 3 and Next.js
Learn how to build a full-featured content-driven website using Payload CMS 3, which runs natively inside Next.js App Router. This tutorial covers collections, rich text editing, media uploads, authentication, and deploying to production.