TanStack Query with Next.js¶
Patterns for integrating TanStack Query v5 with the Next.js App Router — prefetching on the server, hydrating on the client, and coordinating TanStack Query's cache with Next.js caching layers.
For TanStack Query client-side basics (query keys, mutations, Zustand), see State Management. For Axios client setup and hook organization, see API Integration. This guide focuses on the App Router integration layer.
Provider Setup¶
TanStack Query requires a QueryClientProvider at the top of the component tree. In the App Router you must create the QueryClient per request (not as a module-level singleton) to avoid leaking data between users during SSR.
Provider Component¶
// src/providers/query-provider.tsx
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
// Prevent immediate refetch on the client after SSR hydration
staleTime: 60 * 1000,
// Keep unused cache entries for 5 minutes
gcTime: 5 * 60 * 1000,
},
},
});
}
let browserQueryClient: QueryClient | undefined;
function getQueryClient() {
if (typeof window === "undefined") {
// Server: always create a new client (isolate per request)
return makeQueryClient();
}
// Browser: reuse the same client across renders
if (!browserQueryClient) {
browserQueryClient = makeQueryClient();
}
return browserQueryClient;
}
export function QueryProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(getQueryClient);
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}
Wiring into the Root Layout¶
// app/layout.tsx
import { QueryProvider } from "@/providers/query-provider";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<QueryProvider>{children}</QueryProvider>
</body>
</html>
);
}
Default Options for SSR¶
| Option | Recommended Value | Why |
|---|---|---|
staleTime |
60_000 (60 s) |
Prevents the client from immediately refetching data that was just fetched on the server |
gcTime |
300_000 (5 min) |
Keeps dehydrated data in the cache long enough for navigation back to previous pages |
retry |
false (for SSR) |
Avoids blocking the server render with retries — let the client retry instead |
Server-Side Prefetching¶
The core pattern: call prefetchQuery in a Server Component, dehydrate the cache, and wrap the client subtree in HydrationBoundary. The client picks up the prefetched data instantly — no loading spinner on first render.
Basic Prefetch Pattern¶
// app/products/page.tsx
import {
dehydrate,
HydrationBoundary,
QueryClient,
} from "@tanstack/react-query";
import { ProductList } from "./product-list";
export default async function ProductsPage() {
const queryClient = new QueryClient();
await queryClient.prefetchQuery({
queryKey: ["products", "list"],
queryFn: () =>
fetch("https://api.sartiq.com/v1/products").then((res) => res.json()),
});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<ProductList />
</HydrationBoundary>
);
}
// app/products/product-list.tsx
"use client";
import { useQuery } from "@tanstack/react-query";
export function ProductList() {
// This query is already prefetched — renders instantly
const { data: products } = useQuery({
queryKey: ["products", "list"],
queryFn: () =>
fetch("https://api.sartiq.com/v1/products").then((res) => res.json()),
});
return (
<ul>
{products?.map((product: { id: string; name: string }) => (
<li key={product.id}>{product.name}</li>
))}
</ul>
);
}
Multiple Prefetches¶
When a page needs several queries, fire them in parallel:
export default async function DashboardPage() {
const queryClient = new QueryClient();
await Promise.all([
queryClient.prefetchQuery({
queryKey: ["products", "list"],
queryFn: fetchProducts,
}),
queryClient.prefetchQuery({
queryKey: ["analytics", "overview"],
queryFn: fetchAnalytics,
}),
queryClient.prefetchQuery({
queryKey: ["orders", "recent"],
queryFn: fetchRecentOrders,
}),
]);
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<DashboardContent />
</HydrationBoundary>
);
}
Nested Prefetch Boundaries¶
Each Server Component can provide its own HydrationBoundary. This is useful for nested layouts where different segments prefetch different data:
// app/products/[id]/page.tsx
import {
dehydrate,
HydrationBoundary,
QueryClient,
} from "@tanstack/react-query";
import { ProductDetail } from "./product-detail";
import { RelatedProducts } from "./related-products";
export default async function ProductPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const queryClient = new QueryClient();
await Promise.all([
queryClient.prefetchQuery({
queryKey: ["products", "detail", id],
queryFn: () => fetchProduct(id),
}),
queryClient.prefetchQuery({
queryKey: ["products", "related", id],
queryFn: () => fetchRelatedProducts(id),
}),
]);
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<ProductDetail id={id} />
<RelatedProducts productId={id} />
</HydrationBoundary>
);
}
Suspense Queries¶
TanStack Query v5 provides useSuspenseQuery and useSuspenseInfiniteQuery that integrate with React Suspense boundaries. Combined with server prefetching, this gives you type-safe data (no undefined checks) and streaming support.
Basic Suspense Query¶
"use client";
import { useSuspenseQuery } from "@tanstack/react-query";
import type { Product } from "@/types";
export function ProductDetail({ id }: { id: string }) {
// data is always defined — no loading/error states to handle here
const { data: product } = useSuspenseQuery({
queryKey: ["products", "detail", id],
queryFn: () => fetchProduct(id),
});
return (
<div>
<h2>{product.name}</h2>
<p>{product.description}</p>
<span>${product.price}</span>
</div>
);
}
Streaming with Suspense Boundaries¶
Wrap suspense-based components in <Suspense> to stream them progressively:
// app/products/[id]/page.tsx
import { Suspense } from "react";
import {
dehydrate,
HydrationBoundary,
QueryClient,
} from "@tanstack/react-query";
import { ProductDetail } from "./product-detail";
import { ProductReviews } from "./product-reviews";
export default async function ProductPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const queryClient = new QueryClient();
// Only prefetch the critical data — let reviews stream in
await queryClient.prefetchQuery({
queryKey: ["products", "detail", id],
queryFn: () => fetchProduct(id),
});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<ProductDetail id={id} />
<Suspense fallback={<ReviewsSkeleton />}>
<ProductReviews productId={id} />
</Suspense>
</HydrationBoundary>
);
}
Suspense Infinite Query¶
"use client";
import { useSuspenseInfiniteQuery } from "@tanstack/react-query";
export function NotificationFeed() {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
useSuspenseInfiniteQuery({
queryKey: ["notifications"],
queryFn: ({ pageParam }) => fetchNotifications({ cursor: pageParam }),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
const notifications = data.pages.flatMap((page) => page.items);
return (
<div>
{notifications.map((n) => (
<NotificationCard key={n.id} notification={n} />
))}
{hasNextPage && (
<button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
{isFetchingNextPage ? "Loading..." : "Load more"}
</button>
)}
</div>
);
}
useQuery vs useSuspenseQuery¶
| Feature | useQuery |
useSuspenseQuery |
|---|---|---|
data type |
T \| undefined |
T (always defined) |
| Loading state | Manual via isPending |
Handled by <Suspense> boundary |
| Error state | Manual via isError |
Handled by <ErrorBoundary> |
| Streaming support | No | Yes |
enabled option |
Supported | Not supported (use skipToken instead) |
Mutations & Server Actions¶
Combine useMutation with Next.js Server Actions for mutations that need both client-side optimistic updates and server-side revalidation.
Basic Mutation with Server Action¶
// app/products/actions.ts
"use server";
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma";
export async function createProduct(data: {
name: string;
price: number;
}) {
const product = await prisma.product.create({ data });
revalidatePath("/products");
return product;
}
// app/products/create-product-form.tsx
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { createProduct } from "./actions";
export function CreateProductForm() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: createProduct,
onSuccess: () => {
// Invalidate TanStack Query cache so lists refetch
queryClient.invalidateQueries({ queryKey: ["products"] });
},
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
mutation.mutate({
name: formData.get("name") as string,
price: parseFloat(formData.get("price") as string),
});
}}
>
<input name="name" placeholder="Product name" required />
<input name="price" type="number" step="0.01" required />
<button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? "Creating..." : "Create Product"}
</button>
{mutation.isError && (
<p className="text-red-500">Failed to create product</p>
)}
</form>
);
}
Optimistic Updates¶
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { updateProduct } from "./actions";
import type { Product } from "@/types";
export function useOptimisticUpdateProduct(id: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: Partial<Product>) => updateProduct(id, data),
onMutate: async (newData) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({
queryKey: ["products", "detail", id],
});
// Snapshot current value
const previous = queryClient.getQueryData<Product>([
"products",
"detail",
id,
]);
// Optimistically update
queryClient.setQueryData<Product>(
["products", "detail", id],
(old) => (old ? { ...old, ...newData } : old),
);
return { previous };
},
onError: (_err, _newData, context) => {
// Roll back on error
if (context?.previous) {
queryClient.setQueryData(
["products", "detail", id],
context.previous,
);
}
},
onSettled: () => {
// Refetch to ensure server state is in sync
queryClient.invalidateQueries({
queryKey: ["products", "detail", id],
});
queryClient.invalidateQueries({ queryKey: ["products", "list"] });
},
});
}
Dual Revalidation¶
When a mutation changes data, you often need to revalidate both caching layers:
| Layer | How to Revalidate | When |
|---|---|---|
| TanStack Query cache | queryClient.invalidateQueries() |
Always — keeps client components fresh |
| Next.js cache (Data/Route) | revalidatePath() / revalidateTag() in Server Action |
When Server Components also display this data |
// Server Action: revalidates Next.js caches
"use server";
export async function deleteProduct(id: string) {
await prisma.product.delete({ where: { id } });
revalidatePath("/products");
revalidateTag("product-detail");
}
// Client: also invalidates TanStack Query cache
const mutation = useMutation({
mutationFn: deleteProduct,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["products"] });
},
});
Infinite Queries & Pagination¶
Cursor-Based Infinite Scroll¶
"use client";
import { useInfiniteQuery } from "@tanstack/react-query";
import { useInView } from "react-intersection-observer";
import { useEffect } from "react";
interface ProductPage {
items: Product[];
nextCursor: string | null;
}
export function ProductInfiniteList() {
const { ref, inView } = useInView();
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isPending } =
useInfiniteQuery<ProductPage>({
queryKey: ["products", "infinite"],
queryFn: ({ pageParam }) =>
fetch(`/api/products?cursor=${pageParam ?? ""}&limit=20`).then((res) =>
res.json(),
),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
// Auto-fetch when sentinel enters viewport
useEffect(() => {
if (inView && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);
if (isPending) return <ProductGridSkeleton />;
const products = data.pages.flatMap((page) => page.items);
return (
<div>
<div className="grid grid-cols-3 gap-4">
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
<div ref={ref} className="h-10">
{isFetchingNextPage && <Spinner />}
</div>
</div>
);
}
Prefetching the First Page on the Server¶
// app/products/page.tsx
import {
dehydrate,
HydrationBoundary,
QueryClient,
} from "@tanstack/react-query";
import { ProductInfiniteList } from "./product-infinite-list";
export default async function ProductsPage() {
const queryClient = new QueryClient();
await queryClient.prefetchInfiniteQuery({
queryKey: ["products", "infinite"],
queryFn: () =>
fetch("https://api.sartiq.com/v1/products?limit=20").then((res) =>
res.json(),
),
initialPageParam: undefined,
});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<h1>Products</h1>
<ProductInfiniteList />
</HydrationBoundary>
);
}
Offset Pagination¶
For traditional page-based pagination, use a regular useQuery with the page number in the key:
"use client";
import { useQuery, keepPreviousData } from "@tanstack/react-query";
import { useState } from "react";
export function PaginatedProducts() {
const [page, setPage] = useState(1);
const { data, isPlaceholderData } = useQuery({
queryKey: ["products", "list", { page }],
queryFn: () =>
fetch(`/api/products?page=${page}&limit=20`).then((res) => res.json()),
placeholderData: keepPreviousData,
});
return (
<div className={isPlaceholderData ? "opacity-50" : ""}>
<ProductGrid products={data?.items ?? []} />
<div className="flex gap-2 mt-4">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
>
Previous
</button>
<span>Page {page}</span>
<button
onClick={() => setPage((p) => p + 1)}
disabled={!data?.hasMore}
>
Next
</button>
</div>
</div>
);
}
keepPreviousData keeps the current page visible while the next page loads, avoiding a flash to a loading state.
Prefetching on Interaction¶
Prefetch data before the user navigates to reduce perceived latency. This complements Next.js <Link> route prefetching — <Link> prefetches the route's RSC payload, while queryClient.prefetchQuery prefetches the data the client components will need.
Prefetch on Hover¶
"use client";
import Link from "next/link";
import { useQueryClient } from "@tanstack/react-query";
interface ProductLinkProps {
id: string;
name: string;
}
export function ProductLink({ id, name }: ProductLinkProps) {
const queryClient = useQueryClient();
function handlePrefetch() {
queryClient.prefetchQuery({
queryKey: ["products", "detail", id],
queryFn: () => fetchProduct(id),
staleTime: 60 * 1000,
});
}
return (
<Link
href={`/products/${id}`}
onMouseEnter={handlePrefetch}
onFocus={handlePrefetch}
>
{name}
</Link>
);
}
Prefetch in Event Handlers¶
Trigger prefetching from any interaction — useful for modals, tabs, or accordion panels:
"use client";
import { useQueryClient } from "@tanstack/react-query";
export function ProductTabs({ productId }: { productId: string }) {
const queryClient = useQueryClient();
function handleTabHover(tab: string) {
if (tab === "reviews") {
queryClient.prefetchQuery({
queryKey: ["products", "reviews", productId],
queryFn: () => fetchProductReviews(productId),
staleTime: 30 * 1000,
});
}
if (tab === "specs") {
queryClient.prefetchQuery({
queryKey: ["products", "specs", productId],
queryFn: () => fetchProductSpecs(productId),
staleTime: 5 * 60 * 1000,
});
}
}
return (
<div role="tablist">
<button role="tab" onMouseEnter={() => handleTabHover("reviews")}>
Reviews
</button>
<button role="tab" onMouseEnter={() => handleTabHover("specs")}>
Specs
</button>
</div>
);
}
Cache Coordination¶
TanStack Query and Next.js each have their own caching layers. Understanding when to use which — and how they interact — prevents stale data and unnecessary fetches.
Cache Layer Overview¶
Browser
├── TanStack Query cache (in-memory, client)
│ └── Populated by: useQuery, prefetchQuery + dehydrate
└── Next.js Router Cache (in-memory, client)
└── Populated by: <Link> prefetch, router.prefetch()
Server
├── Next.js Data Cache (persistent)
│ └── Populated by: fetch() with next.revalidate or next.tags
└── Next.js Full Route Cache (persistent)
└── Populated by: static routes at build time
When to Use Each Approach¶
| Scenario | Approach | Why |
|---|---|---|
| Static content, rarely changes | Server Component + Data Cache | No JS shipped, cached at CDN edge |
| Data shown once, no interactivity | Server Component with fetch |
Simplest path, no client overhead |
| Interactive list with filters/sorting | TanStack Query (useQuery) |
Client-side state drives refetches |
| Real-time or frequently updating data | TanStack Query with refetchInterval |
Polling/WebSocket integration built in |
| Form with optimistic updates | TanStack Query (useMutation) |
Optimistic UI and rollback support |
| Infinite scroll / pagination | TanStack Query (useInfiniteQuery) |
Page param management built in |
| Data needed by both Server and Client Components | Prefetch in Server Component + HydrationBoundary |
Server renders first, client hydrates without refetch |
Dual Revalidation Strategies¶
When data is displayed by both Server Components and Client Components using TanStack Query, you need to invalidate both caching systems on mutation:
// 1. Server Action: invalidates Next.js Data Cache + Full Route Cache
"use server";
import { revalidatePath, revalidateTag } from "next/cache";
export async function updateProduct(id: string, data: ProductUpdate) {
await prisma.product.update({ where: { id }, data });
revalidatePath("/products"); // Next.js route cache
revalidateTag(`product-${id}`); // Next.js data cache
return { success: true };
}
// 2. Client Component: also invalidates TanStack Query cache
"use client";
const mutation = useMutation({
mutationFn: (data: ProductUpdate) => updateProduct(productId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["products"] });
router.refresh(); // Force Next.js Router Cache to refetch
},
});
Avoiding Conflicts¶
| Pitfall | Solution |
|---|---|
| TanStack Query refetches on mount after SSR | Set staleTime >= 60_000 in default options |
| Next.js Router Cache serves stale RSC payload | Call router.refresh() after mutations |
Duplicate fetches: Server Component + client useQuery |
Use prefetchQuery + HydrationBoundary so the client reuses server data |
| Different data in Server vs Client render | Ensure queryKey and queryFn match exactly between prefetch and useQuery |
DevTools & Debugging¶
Lazy-Loaded DevTools¶
Load DevTools only in development to avoid bundling them in production:
// src/providers/query-provider.tsx
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { useState } from "react";
export function QueryProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(getQueryClient);
return (
<QueryClientProvider client={queryClient}>
{children}
{process.env.NODE_ENV === "development" && (
<ReactQueryDevtools initialIsOpen={false} buttonPosition="bottom-left" />
)}
</QueryClientProvider>
);
}
For production debugging without shipping DevTools in the main bundle, use dynamic import:
"use client";
import { lazy, Suspense } from "react";
const ReactQueryDevtoolsProduction = lazy(() =>
import("@tanstack/react-query-devtools/build/modern/production.js").then(
(d) => ({ default: d.ReactQueryDevtools }),
),
);
export function DevTools() {
const [showDevtools, setShowDevtools] = useState(false);
useEffect(() => {
// Toggle devtools with Ctrl+Shift+D
function handleKeyDown(e: KeyboardEvent) {
if (e.ctrlKey && e.shiftKey && e.key === "D") {
setShowDevtools((prev) => !prev);
}
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, []);
if (!showDevtools) return null;
return (
<Suspense fallback={null}>
<ReactQueryDevtoolsProduction />
</Suspense>
);
}
Inspecting Cache State¶
Use the query client directly to debug cache issues:
// In the browser console or a debug component
const queryClient = useQueryClient();
// View all cached queries
console.log(queryClient.getQueryCache().getAll());
// Check a specific query's state
console.log(
queryClient.getQueryState(["products", "detail", "abc123"]),
);
// Force a specific query to refetch
queryClient.refetchQueries({ queryKey: ["products", "detail", "abc123"] });
// Clear the entire cache
queryClient.clear();
Decision Table¶
Use this table to decide which data-fetching approach fits each use case.
| Use Case | Approach | Notes |
|---|---|---|
| Static marketing page | Server Component + fetch |
No TanStack Query needed. Use next.revalidate for ISR. |
| Product detail (server-rendered, interactive) | prefetchQuery + useSuspenseQuery |
Server renders instantly, client hydrates. Best of both worlds. |
| Dashboard with live filters | useQuery with filter state |
Filters change query keys, triggering refetches. No server prefetch needed. |
| Infinite scroll feed | useInfiniteQuery + prefetchInfiniteQuery |
Prefetch the first page on the server, load more on the client. |
| Form submission | useMutation + Server Action |
Server Action handles validation and DB write. TanStack Query handles optimistic UI. |
| Periodic polling (e.g., job status) | useQuery with refetchInterval |
Set refetchInterval: 3000 for 3-second polling. |
| Data only shown in Server Components | Server Component + fetch |
No TanStack Query. Use revalidatePath/revalidateTag for freshness. |
| Shared data (server-rendered + interactive) | prefetchQuery + HydrationBoundary + useQuery |
Dual revalidation strategy needed on mutations. |
| Search-as-you-type | useQuery with debounced input |
Combine with placeholderData: keepPreviousData for smooth UX. |