Skip to content

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.