Type something to search...
Client-side data fetching with TanStack Query

Client-side data fetching with TanStack Query

TanStack Query (the library most people still call React Query out of habit) solves a problem that Server Components don't: what happens to data after the page has loaded. Server Components are excellent at getting the first byte of HTML in front of a user fast, but the moment you need to search-as-you-type, poll for new messages, retry a failed request, or keep a screen fresh while the user tabs away and back, you're back in the browser, managing state that lives entirely on the client. That's TanStack Query's job, and it does it well — but wiring it into the App Router raises a few questions the library's own docs don't answer, because they assume you're not also dealing with Server Components, Suspense boundaries, and a second, server-side cache sitting underneath everything.

This guide walks through setting TanStack Query up in a Next.js App Router project, from the basic provider to the trickier pattern of seeding a client query with data that was already fetched on the server — and, if you've enabled Cache Components, keeping that server data properly cached and invalidated too. None of this is exotic once you've done it once, but the first time through, the "why do I need two caches for one piece of data" question trips up almost everyone.

Why You'd Reach for TanStack Query Instead of Just fetch

If your data only needs to load once, when a Server Component renders, you don't need TanStack Query at all — fetch in a Server Component (or a cached function with use cache) is simpler and sends less JavaScript to the browser. TanStack Query earns its keep when the data has to change after the initial render without a full navigation: autocomplete results as someone types, a notification badge that polls every 30 seconds, an optimistic UI update that needs to roll back if a mutation fails, or a query that several unrelated components on the page all want to share without prop-drilling it down.

The core value TanStack Query adds on top of "just call fetch in a useEffect" is a request cache keyed by an array you control (the queryKey), automatic deduplication of identical in-flight requests, background refetching, and a consistent way to express loading and error states. Once you've used it, hand-rolling that logic with useState and useEffect feels like reinventing a wheel that TanStack Query already shipped, tested, and documented.

Step 1: Install the Library and Set Up the Provider

Install the package the normal way:

npm install @tanstack/react-query

TanStack Query needs a QueryClient instance, and that instance has to be provided through context via QueryClientProvider. Here's the part that's easy to get wrong in Next.js specifically: if you create the QueryClient at module scope, every request on the server shares the exact same client instance, which means one user's cached data can leak into another user's response. You need a fresh client per server render, but a single, stable client in the browser that survives re-renders.

// app/products/providers.tsx
"use client";

import type { ReactNode } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

let browserQueryClient: QueryClient | undefined;

function getQueryClient() {
  // On the server, always return a new client — there's no per-user
  // state to share across requests, and sharing one would be a real bug.
  if (typeof window === "undefined") return new QueryClient();

  // In the browser, create the client once and reuse it across renders.
  browserQueryClient ??= new QueryClient();
  return browserQueryClient;
}

export function Providers({ children }: { children: ReactNode }) {
  return (
    <QueryClientProvider client={getQueryClient()}>
      {children}
    </QueryClientProvider>
  );
}

Notice this file is a Client Component ('use client' at the top). QueryClientProvider relies on React context, and context providers can't be Server Components. Render Providers from the nearest layout that actually needs TanStack Query — you don't have to wrap your entire app if only one route segment uses it:

// app/products/layout.tsx
import { Providers } from "./providers";

export default function Layout({ children }: LayoutProps<"/products">) {
  return <Providers>{children}</Providers>;
}

Scoping the provider to just the /products segment rather than the root layout keeps the Client Component boundary as small as possible, which matters because everything inside that boundary ships its JavaScript to the browser — a habit worth carrying into every third-party provider you add, not just this one.

Step 2: Fetch Purely on the Client with useQuery

The simplest case is a component that fetches its own data and manages its own loading and error UI. This is the right call when the initial render genuinely has nothing to show until a browser event happens — a search box is the textbook example, because there's no meaningful "before" state to render on the server.

// app/product-autocomplete.tsx
"use client";

import { useQuery } from "@tanstack/react-query";

type Product = { id: string; name: string };

async function searchProducts(query: string): Promise<Product[]> {
  const response = await fetch(
    `/api/products?query=${encodeURIComponent(query)}`,
  );
  if (!response.ok) throw new Error("Failed to fetch products");
  return response.json();
}

export function ProductAutocomplete({ query }: { query: string }) {
  const {
    data = [],
    error,
    isPending,
  } = useQuery({
    queryKey: ["product-search", query],
    queryFn: () => searchProducts(query),
    enabled: query.length > 0,
  });

  if (!query) return null;
  if (error) return <p>Failed to load products.</p>;
  if (isPending) return <p>Loading products...</p>;

  return (
    <ul>
      {data.map((product) => (
        <li key={product.id}>{product.name}</li>
      ))}
    </ul>
  );
}

Two details worth calling out that are easy to skim past. First, queryKey is an array, and TanStack Query treats it as the cache identity — ['product-search', 'shoes'] and ['product-search', 'boots'] are two entirely separate cache entries. Get this wrong (say, by forgetting to include query in the key) and every search will silently return the first result set, because the cache thinks it's the same request. Second, enabled: query.length > 0 isn't just an optimization — without it, useQuery would fire an empty-string search on mount, which is at best wasted bandwidth and at worst a request your backend wasn't designed to handle.

The endpoint being called here, /api/products, is a Route Handler — TanStack Query doesn't care what serves the data, as long as it's reachable from the browser with a relative URL.

Step 3: Use Suspense Instead of Manual Loading States

useQuery gives you isPending and error to check by hand, which is fine for one component but gets repetitive once you have several. useSuspenseQuery hands that responsibility to the nearest <Suspense> boundary instead, which is usually a cleaner split: the boundary owns the loading UI, and the component that reads the query can assume the data is just... there.

// app/product-autocomplete.tsx
"use client";

import { useSuspenseQuery } from "@tanstack/react-query";
import { Suspense } from "react";

type Product = { id: string; name: string };

async function searchProducts(query: string): Promise<Product[]> {
  const response = await fetch(
    `/api/products?query=${encodeURIComponent(query)}`,
  );
  if (!response.ok) throw new Error("Failed to fetch products");
  return response.json();
}

export function ProductAutocomplete({ query }: { query: string }) {
  if (!query) return null;

  return (
    <Suspense fallback={<p>Loading products...</p>}>
      <ProductResults query={query} />
    </Suspense>
  );
}

function ProductResults({ query }: { query: string }) {
  const { data } = useSuspenseQuery({
    queryKey: ["product-search", query],
    queryFn: () => searchProducts(query),
  });

  return (
    <ul>
      {data.map((product) => (
        <li key={product.id}>{product.name}</li>
      ))}
    </ul>
  );
}

The component that calls useSuspenseQuery has to sit below the Suspense boundary, in a separate component — you can't suspend the same component that renders the fallback, since React needs somewhere to render while the query resolves. If the fetch throws, useSuspenseQuery propagates the error up to the nearest error boundary rather than returning an error value you have to check yourself.

One behavior that surprises people coming from useQuery: once a query has successfully resolved once, a later refetch for the same key keeps showing the previous data instead of falling back to the Suspense fallback again. That's deliberate — nobody wants a full-page loading spinner every time a background refetch happens — but if you want to communicate that a refresh is happening, use the isFetching flag from a paired useQuery call or useIsFetching, not the Suspense fallback.

And if you find yourself putting two or more useSuspenseQuery calls in the same component expecting them to run in parallel, you'll be disappointed — they resolve sequentially, because each one throws a promise and the second call doesn't even execute until the first suspends. Split independent queries into sibling components, or reach for useSuspenseQueries, which is built specifically to fetch a set of queries in parallel.

Step 4: Seed the Query with Data Already Fetched on the Server

Fetching everything from scratch in the browser is fine for a search box, but it's a bad look for a page's primary content — nobody wants to see a spinner for data the server could have sent down with the initial HTML. This is where the "provide initial data from a Server Component" pattern comes in: a Server Component fetches the data as usual, and hands it to TanStack Query so the client picks up exactly where the server left off, with no refetch and no flash of loading state.

This requires TanStack Query 5.40.0 or later, because it depends on the ability to dehydrate a pending query, not just a resolved one. The pattern starts a prefetchQuery on the server without awaiting it, and passes the result to a <HydrationBoundary>:

// app/products/[id]/page.tsx
import { Suspense } from "react";
import {
  defaultShouldDehydrateQuery,
  dehydrate,
  HydrationBoundary,
  QueryClient,
} from "@tanstack/react-query";
import { getProduct } from "./data";
import { productCache } from "./product-cache";
import { ProductView } from "./product-view";

export default function Page({ params }: PageProps<"/products/[id]">) {
  return (
    <Suspense fallback={<p>Loading…</p>}>
      {params.then(({ id }) => (
        <ProductData id={id} />
      ))}
    </Suspense>
  );
}

function ProductData({ id }: { id: string }) {
  const queryClient = new QueryClient();

  // Not awaited — this doesn't block rendering.
  void queryClient.prefetchQuery({
    ...productCache.options(id),
    queryFn: () => getProduct(id),
  });

  return (
    <HydrationBoundary
      state={dehydrate(queryClient, {
        shouldDehydrateQuery: (query) =>
          defaultShouldDehydrateQuery(query) ||
          query.state.status === "pending",
      })}
    >
      <ProductView id={id} />
    </HydrationBoundary>
  );
}

The shouldDehydrateQuery override is not optional boilerplate — by default, TanStack Query only dehydrates settled queries, and since this one is deliberately unawaited (so it doesn't block the Server Component from rendering), it's still pending when dehydrate runs. Without the override, the pending query would simply be dropped, and the client would end up fetching from scratch anyway, defeating the entire point of the pattern.

The critical rule that makes this whole thing work is that the server's queryKey and the client's queryKey have to match exactly, or TanStack Query treats them as two unrelated cache entries and the hydration silently does nothing useful. The cleanest way to guarantee that is to define the key and the query options together in one shared module that both the server and client import from:

// app/products/[id]/product-cache.ts
import { queryOptions } from "@tanstack/react-query";

export type Product = { id: string; name: string };

export const productCache = {
  key: (id: string) => ["product", id] as const,
  options: (id: string) =>
    queryOptions({
      queryKey: productCache.key(id),
      queryFn: async (): Promise<Product> => {
        const res = await fetch(`/api/products/${id}`);
        if (!res.ok) throw new Error("Failed to fetch product");
        return res.json();
      },
      staleTime: 30_000,
    }),
};

Notice the queryFn here calls a relative URL, /api/products/${id}. That works fine in the browser, but a relative URL doesn't resolve on the server — which is exactly why the server-side prefetchQuery call above overrides queryFn with a direct call to getProduct(id) instead of using productCache.options(id).queryFn as-is. It's a small but easy-to-miss detail: the query key has to be shared, but the query function is allowed to differ between server and client, because they're solving different problems (one talks to a database directly, the other has to go through HTTP).

staleTime: 30_000 here does something specific: it tells TanStack Query the hydrated data is still "fresh" for 30 seconds after hydration, so the client doesn't immediately kick off a background refetch the instant the page loads. Pick this number based on how often the underlying data actually changes — a product price that updates hourly can tolerate a much longer staleTime than a live chat message count.

The Client Component on the other end just reads the same key:

// app/products/[id]/product-view.tsx
"use client";

import { useSuspenseQuery } from "@tanstack/react-query";
import { productCache } from "./product-cache";

export function ProductView({ id }: { id: string }) {
  const { data } = useSuspenseQuery(productCache.options(id));

  return <h1>{data.name}</h1>;
}

Step 5: Cache the Server-Provided Data with Cache Components

Everything above solves the client-side half of the caching story. But getProduct(id) on the server is presumably still hitting a database on every request unless you cache it too — and this is where TanStack Query's cache and Next.js's server cache genuinely are two separate systems that happen to sit next to each other. If your project has cacheComponents enabled in next.config.ts, you can wrap the server function with use cache, give it a cacheLife profile, and tag it so a later mutation can invalidate it precisely:

// app/products/[id]/data.ts
import { cacheLife, cacheTag } from "next/cache";
import type { Product } from "./product-cache";

export async function getProduct(id: string): Promise<Product> {
  "use cache";
  cacheLife("max");
  cacheTag(`product:${id}`);

  const product = await db.product.findUnique({ where: { id } });
  if (!product) throw new Error("Product not found");
  return product;
}

cacheLife('max') here is a deliberate choice, not a default you should copy blindly: it says "cache this essentially indefinitely, and rely on the tag to invalidate it when it actually changes," which only makes sense if every code path that writes to this product also calls revalidateTag or updateTag on product:${id}. If writes to the underlying data can happen outside your control — a webhook, a background job, a teammate running a raw SQL update — a shorter cacheLife profile with a real revalidate window is the safer default.

It's worth being explicit that cacheLife's stale value and TanStack Query's staleTime are not the same knob, even though they sound like they should be. cacheLife's stale governs how long the Next.js client router cache can reuse a prefetched RSC payload; revalidate and expire govern the server-side cache entry itself. TanStack Query's staleTime is a third, independent setting that only affects TanStack Query's own in-browser cache. You can absolutely set cacheLife('max') on the server function while giving staleTime: 30_000 to the TanStack Query options for the same piece of data — they're not required to agree, because they're managing different caches for different purposes.

There's a sharper edge here if you've turned on Cache Components: it also prerenders Client Components where possible, and dehydrate() internally reads Date.now() while building its state. Reading the current time during prerendering is exactly the kind of dynamic operation Cache Components is designed to catch and reject — you'll see it surface as a "current-time prerender error." Keeping the query that needs this data behind a Suspense boundary sidesteps the problem, because Next.js can defer that work to request time instead of trying to prerender through it. If you hit this error anyway, see the workaround in the section below on building a prerenderable hydration state by hand.

Step 6: Coordinate the Client Cache and the Server Cache After a Mutation

Hydration only sets the initial value of a client query — after that, TanStack Query owns the browser copy, and a mutation needs to update both sides deliberately: the client cache immediately (usually optimistically, for a snappy UI) and the server cache correctly (so the next full page load or fresh navigation reflects reality). The cleanest way to keep these in sync is, again, to share the identity — the query key and the server tag — from one small module:

// app/activity/activity-cache.ts
export const activityCache = {
  key: ["activity", "unread"] as const,
  tag: (userId: string) => `activity:${userId}`,
};

On the client, useMutation's onMutate callback is where you apply the optimistic update, and its onError callback is where you roll it back if the mutation actually fails:

// app/activity/mark-read-button.tsx
"use client";

import { useMutation, useQueryClient } from "@tanstack/react-query";
import { markActivityReadAction } from "./actions";
import { activityCache } from "./activity-cache";

export function MarkReadButton() {
  const queryClient = useQueryClient();
  const queryKey = activityCache.key;

  const markRead = useMutation({
    mutationFn: markActivityReadAction,
    onMutate: async () => {
      await queryClient.cancelQueries({ queryKey });
      const previous = queryClient.getQueryData(queryKey);
      queryClient.setQueryData(queryKey, { count: 0 });
      return { previous };
    },
    onError: (_error, _variables, context) => {
      queryClient.setQueryData(queryKey, context?.previous);
    },
  });

  return <button onClick={() => markRead.mutate()}>Mark read</button>;
}

cancelQueries before applying the optimistic update matters more than it looks — without it, an in-flight background refetch that resolves after your optimistic write could silently overwrite it with stale data, and you'd see the badge flicker back to its old value for a moment. Cancelling first closes that race.

On the server, the mutation is a Server Action that actually writes to the database and then invalidates the corresponding server-side tag with updateTag:

// app/activity/actions.ts
"use server";

import { updateTag } from "next/cache";
import {
  getCurrentUserId,
  markActivityRead as markActivityReadInDatabase,
} from "./data";
import { activityCache } from "./activity-cache";

export async function markActivityReadAction() {
  const userId = await getCurrentUserId();
  await markActivityReadInDatabase(userId);
  updateTag(activityCache.tag(userId));
}

updateTag (rather than revalidateTag) is worth using here specifically because it expires the cache entry immediately, in the same request, rather than merely scheduling a background revalidation — that's the difference that lets a subsequent server render see the fresh value right away instead of possibly serving one more stale response first. And it's only relevant if the underlying read was cached in the first place: an uncached read has no server tag to invalidate, because there's nothing sitting between the read and the database to go stale.

Step 7: The Escape Hatch — Building a Prerenderable Hydration State by Hand

If you've enabled Cache Components and you hit the current-time prerender error mentioned earlier, the fix is to stop letting dehydrate() call Date.now() implicitly, and instead cache just the timestamp yourself, tagged the same way as the underlying data:

// app/lib/hydrate.ts
import "server-only";

import { cacheLife, cacheTag } from "next/cache";
import {
  defaultShouldDehydrateQuery,
  QueryClient,
  type DehydratedState,
  type QueryKey,
} from "@tanstack/react-query";

type HydratedQuery = {
  queryKey: QueryKey;
  data: unknown;
};

type HydrationOptions = {
  tags: string[];
};

async function getHydrationUpdatedAt(tags: string[]) {
  "use cache";
  cacheTag(...tags);
  cacheLife("max");
  return Date.now();
}

export async function dehydrate(
  queries: HydratedQuery[],
  options: HydrationOptions,
): Promise<DehydratedState> {
  const updatedAt = await getHydrationUpdatedAt(options.tags);
  const queryClient = new QueryClient();

  for (const query of queries) {
    queryClient.setQueryData(query.queryKey, query.data, { updatedAt });
  }

  return {
    mutations: [],
    queries: queryClient
      .getQueryCache()
      .getAll()
      .filter((query) => defaultShouldDehydrateQuery(query))
      .map((query) => ({
        dehydratedAt: updatedAt,
        queryHash: query.queryHash,
        queryKey: query.queryKey,
        state: query.state,
        ...(query.meta ? { meta: query.meta } : {}),
      })),
  };
}

Because getHydrationUpdatedAt is itself wrapped in use cache with the same tags as the data it accompanies, the timestamp and the data invalidate together — when a mutation calls updateTag on that tag, both the cached data and the cached "as of" timestamp expire in lockstep, so a stale timestamp never ends up paired with fresh data or vice versa. This is a narrow, specific workaround for tag-driven server data; if your data instead expires on a fixed time window rather than an explicit tag, derive both the data and its timestamp from that same cached snapshot rather than maintaining two independent clocks.

Common Mistakes Worth Watching For

Forgetting the module-scope QueryClient trap. Creating a single QueryClient outside any function and importing it everywhere works in a plain client-only React app, but in Next.js it means every server-rendered request shares one client instance — and one user's cached query result can leak into another user's response. Always create it lazily, per the pattern in Step 1.

Mismatched query keys between server and client. This is the single most common reason the "seed from the server" pattern silently fails to do anything: the hydrated data never shows up because TanStack Query considers the server's key and the client's key two unrelated cache entries. If hydration doesn't seem to be working, this is the first thing to check.

Treating staleTime and cacheLife as the same setting. They're not, and setting one without the other is a common source of "why is this still slow" or "why is this stale" confusion. cacheLife governs the server cache; staleTime governs TanStack Query's own client cache. Both need to be set deliberately for the caching behavior to make sense end-to-end.

Putting multiple useSuspenseQuery calls in one component and expecting parallelism. They run sequentially. If you need two independent queries to load in parallel, split them into sibling components or use useSuspenseQueries.

Key Takeaways

ScenarioWhat to reach for
Search box / autocomplete with no server-renderable initial stateuseQuery with enabled gating the request
Cleaner loading UI, one boundary owns the fallbackuseSuspenseQuery inside a <Suspense> boundary
Server already has the data, avoid a client refetch on loadprefetchQuery + dehydrate + <HydrationBoundary>, with a shared query-key module
Server data needs caching toouse cache + cacheLife + cacheTag on the server-side data function
A mutation needs to update both cachesOptimistic onMutate/onError on the client, updateTag on the server, sharing one tag/key contract
Cache Components rejects dehydrate() for reading the current timeCache the timestamp explicitly with the same tags, build the dehydrated state by hand

TanStack Query and Next.js's own caching model are solving adjacent but different problems, and the friction most people hit comes from expecting one to automatically know about the other. Once you accept that you're deliberately running two caches — one for the browser, one for the server — and give each one its own explicit rules, the rest of the pattern is just plumbing: same key, same tag, invalidate both sides together, and the two systems stay honest with each other.

Tags :
Share :

Related Posts

Can Next.js Be Used with GraphQL?

Can Next.js Be Used with GraphQL?

Next.js and GraphQL are two powerful technologies that have gained significant traction in the web development community. Next.js, a React-based fram

Dive Deeper
How does Next.js differ from Create React App?

How does Next.js differ from Create React App?

In the world of modern web development, React.js has emerged as a dominant force due to its flexibility, performance, and extensive ecosystem. Two po

Dive Deeper
How does Next.js handle image optimization?

How does Next.js handle image optimization?

In modern web development, image optimization plays a critical role in enhancing user experience and improving site performance. Large, unoptimized i

Dive Deeper