Skip to content
Modern Data Fetching: TanStack Query Strategies for Next.js Performance

Modern Data Fetching: TanStack Query Strategies for Next.js Performance

12 min read
Next.jsTanStack QueryReact QueryData FetchingWeb Development

TanStack Query revolutionizes data management in Next.js, offering robust caching and automatic re-fetching. Discover advanced strategies to build performant and highly reactive web applications that delight users.

Mastering Data Fetching in Next.js with TanStack Query

TL;DR: If your Next.js app fetches data that changes — dashboards, feeds, forms, anything interactive — TanStack Query (v5) deletes most of your data-fetching boilerplate. This guide covers setup in App Router, useQuery patterns, mutations, optimistic updates, and server-side hydration, with complete runnable examples.

The problem with "just use fetch"

Next.js gives you plenty of ways to fetch data: fetch in Server Components, useEffect + useState on the client, route handlers, server actions. For a static marketing page, that's plenty.

But the moment your UI becomes interactive — filters, pagination, likes, live dashboards, offline tolerance — hand-rolled fetching collapses into the same pile of problems:

  • Boilerplate everywhere: every fetch needs isLoading, isError, data, and retry logic.
  • Stale data: users stare at yesterday's numbers until someone remembers to refetch.
  • Race conditions: two in-flight requests resolve out of order and the UI shows the wrong one.
  • No real cache: every mount re-fetches; deduplicating identical requests is DIY.
  • Prop drilling: refetch functions get threaded through five layers of components.
  • Optimistic UI: instant-feeling mutations with rollback are genuinely hard to get right by hand.

This is server state — data you don't own, that can change under you — and it deserves its own manager. That's TanStack Query.

What TanStack Query actually does for you

ConcernHand-rolled useEffectTanStack Query
Loading / error statesManual flags per componentBuilt into every hook
CachingDIY (usually none)Automatic, keyed, deduped
Background refetchingDIYStale-while-revalidate out of the box
Request deduplicationDIYAutomatic
Optimistic updatesDIY + rollback bugsonMutate / onError / onSettled lifecycle
Devtoolsconsole.logFirst-class devtools panel
SSR hydrationDIY serializationdehydrate / HydrationBoundary

The mental model is simple: you declare what data a component needs via a query key, and the library handles how it gets fetched, cached, and kept fresh.

Setup in Next.js App Router

1. Install

BASH
npm install @tanstack/react-query @tanstack/react-query-devtools

2. Create the provider

The QueryClient must be created fresh per request on the server, but shared in the browser. The cleanest App Router pattern uses useState to lazily create one client per component lifetime:

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

import { useState } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";

export default function Providers({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(
    () =>
      new QueryClient({
        defaultOptions: {
          queries: {
            // Data is "fresh" for 60s — no refetch on remount within this window
            staleTime: 60 * 1000,
            // Don't refetch just because the user alt-tabbed back
            refetchOnWindowFocus: false,
          },
        },
      })
  );

  return (
    <QueryClientProvider client={queryClient}>
      {children}
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
}

3. Wire it into the root layout

TSX
// app/layout.tsx
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import Providers from "./providers";

const inter = Inter({ subsets: ["latin"] });

export const metadata: Metadata = {
  title: "Next.js + TanStack Query",
  description: "Advanced data fetching patterns with TanStack Query v5",
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body className={inter.className}>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}

That's the entire setup. Everything below builds on it.

Basic data fetching with useQuery

TSX
// components/PostsList.tsx
"use client";

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

interface Post {
  id: number;
  title: string;
  body: string;
}

async function fetchPosts(): Promise<Post[]> {
  const res = await fetch("https://jsonplaceholder.typicode.com/posts");
  if (!res.ok) throw new Error("Failed to fetch posts");
  return res.json();
}

export default function PostsList() {
  const { data, isLoading, isError, error } = useQuery({
    queryKey: ["posts"],
    queryFn: fetchPosts,
  });

  if (isLoading) return <p>Loading posts…</p>;
  if (isError) return <p>Error: {error.message}</p>;

  return (
    <section>
      <h2>Posts</h2>
      <ul>
        {data?.map((post) => (
          <li key={post.id}>
            <h3>{post.title}</h3>
            <p>{post.body}</p>
          </li>
        ))}
      </ul>
    </section>
  );
}

No useState, no useEffect, no manual flags. Mount a second component with queryKey: ["posts"] and it reads from cache instantly — the library dedupes the network request and revalidates in the background.

Advanced useQuery patterns

Dependent queries

Fetch B only after A resolves, using enabled:

TSX
// components/UserDetails.tsx
"use client";

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

interface User {
  id: number;
  name: string;
  email: string;
}

interface UserPost {
  id: number;
  title: string;
}

async function fetchUser(userId: number): Promise<User> {
  const res = await fetch(
    `https://jsonplaceholder.typicode.com/users/${userId}`
  );
  if (!res.ok) throw new Error("Failed to fetch user");
  return res.json();
}

async function fetchUserPosts(userId: number): Promise<UserPost[]> {
  const res = await fetch(
    `https://jsonplaceholder.typicode.com/posts?userId=${userId}`
  );
  if (!res.ok) throw new Error("Failed to fetch user posts");
  return res.json();
}

export default function UserDetails({ userId }: { userId: number }) {
  const {
    data: user,
    isLoading: userLoading,
    isError: userError,
  } = useQuery({
    queryKey: ["user", userId],
    queryFn: () => fetchUser(userId),
  });

  const {
    data: posts,
    isLoading: postsLoading,
    isError: postsError,
  } = useQuery({
    queryKey: ["user", userId, "posts"],
    queryFn: () => fetchUserPosts(userId),
    enabled: !!user, // waits for the user query to succeed
  });

  if (userLoading || postsLoading) return <p>Loading user details…</p>;
  if (userError || postsError) return <p>Something went wrong.</p>;

  return (
    <section>
      <h2>{user?.name}</h2>
      <p>{user?.email}</p>
      <h3>Posts by {user?.name}</h3>
      <ul>
        {posts?.map((post) => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </section>
  );
}

Note the hierarchical key ["user", userId, "posts"] — invalidating ["user", userId] later cascades to it. Key design is cache design.

Parallel queries

Independent queries just run side by side — no orchestration needed:

TSX
// components/Dashboard.tsx
"use client";

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

async function fetchVisitors(): Promise<{ value: number }> {
  const res = await fetch("/api/visitors");
  if (!res.ok) throw new Error("Failed to fetch visitors");
  return res.json();
}

async function fetchSales(): Promise<{ value: number }> {
  const res = await fetch("/api/sales");
  if (!res.ok) throw new Error("Failed to fetch sales");
  return res.json();
}

export default function Dashboard() {
  const { data: visitors, isLoading: visitorsLoading } = useQuery({
    queryKey: ["visitors"],
    queryFn: fetchVisitors,
  });
  const { data: sales, isLoading: salesLoading } = useQuery({
    queryKey: ["sales"],
    queryFn: fetchSales,
  });

  if (visitorsLoading || salesLoading) return <p>Loading dashboard…</p>;

  return (
    <section>
      <h2>Dashboard</h2>
      <p>Total visitors: {visitors?.value}</p>
      <p>Total sales: {sales?.value}</p>
    </section>
  );
}

(For a dynamic number of parallel queries, reach for useQueries instead.)

Infinite scrolling with useInfiniteQuery

TSX
// components/InfinitePosts.tsx
"use client";

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

interface Post {
  id: number;
  title: string;
  body: string;
}

interface PostsPage {
  posts: Post[];
  nextCursor: number | undefined;
}

async function fetchPostsPage(pageParam: number): Promise<PostsPage> {
  const limit = 10;
  const res = await fetch(
    `https://jsonplaceholder.typicode.com/posts?_start=${pageParam}&_limit=${limit}`
  );
  if (!res.ok) throw new Error("Failed to fetch posts");
  const posts: Post[] = await res.json();
  return {
    posts,
    nextCursor: posts.length === limit ? pageParam + limit : undefined,
  };
}

export default function InfinitePosts() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    isLoading,
    isError,
    error,
  } = useInfiniteQuery({
    queryKey: ["infinitePosts"],
    queryFn: ({ pageParam }) => fetchPostsPage(pageParam),
    initialPageParam: 0,
    getNextPageParam: (lastPage) => lastPage.nextCursor,
  });

  if (isLoading) return <p>Loading posts…</p>;
  if (isError) return <p>Error: {error.message}</p>;

  return (
    <section>
      <h2>Infinite posts</h2>
      {data?.pages.map((page, i) => (
        <div key={i}>
          {page.posts.map((post) => (
            <article key={post.id}>
              <h3>{post.title}</h3>
              <p>{post.body}</p>
            </article>
          ))}
        </div>
      ))}
      <button
        onClick={() => fetchNextPage()}
        disabled={!hasNextPage || isFetchingNextPage}
      >
        {isFetchingNextPage
          ? "Loading more…"
          : hasNextPage
            ? "Load more"
            : "Nothing more to load"}
      </button>
    </section>
  );
}

useInfiniteQuery keeps every fetched page in the cache, so scrolling back up never re-fetches.

Mutations with useMutation

Queries read; mutations write. On success, invalidate the affected queries so the UI converges on the truth:

TSX
// components/CreatePostForm.tsx
"use client";

import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";

interface NewPost {
  title: string;
  body: string;
  userId: number;
}

async function createPost(newPost: NewPost): Promise<NewPost & { id: number }> {
  const res = await fetch("https://jsonplaceholder.typicode.com/posts", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(newPost),
  });
  if (!res.ok) throw new Error("Failed to create post");
  return res.json();
}

export default function CreatePostForm() {
  const queryClient = useQueryClient();
  const [title, setTitle] = useState("");
  const [body, setBody] = useState("");

  const mutation = useMutation({
    mutationFn: createPost,
    onSuccess: () => {
      // Mark ["posts"] as stale — it refetches on next read
      queryClient.invalidateQueries({ queryKey: ["posts"] });
      setTitle("");
      setBody("");
    },
  });

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    mutation.mutate({ title, body, userId: 1 });
  };

  return (
    <form onSubmit={handleSubmit}>
      <h2>Create post</h2>
      <label>
        Title
        <input
          value={title}
          onChange={(e) => setTitle(e.target.value)}
          disabled={mutation.isPending}
          required
        />
      </label>
      <label>
        Body
        <textarea
          value={body}
          onChange={(e) => setBody(e.target.value)}
          disabled={mutation.isPending}
          required
        />
      </label>
      <button type="submit" disabled={mutation.isPending}>
        {mutation.isPending ? "Creating…" : "Create post"}
      </button>
      {mutation.isError && <p>Error: {mutation.error.message}</p>}
    </form>
  );
}

Optimistic updates

Invalidation is correct but feels slow — the UI waits for a round trip. Optimistic updates flip it: update the cache immediately, roll back on failure, then reconcile with the server:

TSX
// components/TodoItem.tsx
"use client";

import { useMutation, useQueryClient } from "@tanstack/react-query";

interface Todo {
  id: number;
  title: string;
  completed: boolean;
}

async function updateTodoStatus(todo: Todo): Promise<Todo> {
  const res = await fetch(
    `https://jsonplaceholder.typicode.com/todos/${todo.id}`,
    {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(todo),
    }
  );
  if (!res.ok) throw new Error("Failed to update todo");
  return res.json();
}

export default function TodoItem({ todo }: { todo: Todo }) {
  const queryClient = useQueryClient();

  const mutation = useMutation({
    mutationFn: updateTodoStatus,

    // 1. Runs before the network request
    onMutate: async (newTodo) => {
      // Stop any in-flight refetches that could overwrite us
      await queryClient.cancelQueries({ queryKey: ["todos"] });

      // Snapshot for rollback
      const previousTodos = queryClient.getQueryData<Todo[]>(["todos"]);

      // Optimistically apply the change
      queryClient.setQueryData<Todo[]>(["todos"], (old) =>
        old ? old.map((t) => (t.id === newTodo.id ? newTodo : t)) : []
      );

      return { previousTodos };
    },

    // 2. Roll back if the request fails
    onError: (_err, _newTodo, context) => {
      queryClient.setQueryData(["todos"], context?.previousTodos);
    },

    // 3. Always reconcile with the server afterwards
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ["todos"] });
    },
  });

  return (
    <div>
      <span>{todo.title}</span>
      <button
        onClick={() => mutation.mutate({ ...todo, completed: !todo.completed })}
        disabled={mutation.isPending}
      >
        {todo.completed ? "Mark incomplete" : "Mark complete"}
      </button>
    </div>
  );
}

The onMutate → onError → onSettled trio is the whole pattern. Learn it once, reuse it for likes, toggles, cart updates, and drag-and-drop ordering.

Server Components + initial data (SSR hydration)

useQuery runs on the client, but you don't have to pay for an empty first paint. Prefetch on the server, dehydrate the cache, and hydrate it on the client:

TSX
// lib/getPosts.ts
export interface Post {
  id: number;
  title: string;
  body: string;
}

export async function getPosts(): Promise<Post[]> {
  const res = await fetch("https://jsonplaceholder.typicode.com/posts", {
    // Opt out of Next's fetch cache — TanStack owns caching here
    cache: "no-store",
  });
  if (!res.ok) throw new Error("Failed to fetch posts");
  return res.json();
}
TSX
// app/page.tsx — a Server Component
import {
  dehydrate,
  HydrationBoundary,
  QueryClient,
} from "@tanstack/react-query";
import { getPosts } from "@/lib/getPosts";
import PostsList from "@/components/PostsList";

export default async function HomePage() {
  const queryClient = new QueryClient();

  await queryClient.prefetchQuery({
    queryKey: ["posts"],
    queryFn: getPosts,
  });

  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      {/* PostsList calls useQuery(["posts"]) and finds a warm cache — no client fetch */}
      <PostsList />
    </HydrationBoundary>
  );
}

The result: server-rendered HTML on first paint, then the client cache takes over for instant navigations and background revalidation. Note cache: "no-store" — when TanStack Query owns the cache, letting Next's fetch cache sit in front of it causes double-caching confusion.

Best practices

1. Treat query keys as a schema

Keep them in one place so invalidation stays predictable:

TS
// lib/queryKeys.ts
export const queryKeys = {
  posts: ["posts"],
  post: (id: number) => ["posts", id],
  user: (id: number) => ["user", id],
  userPosts: (id: number) => ["user", id, "posts"],
} as const;

2. Handle errors globally, not per-hook

TSX
import { QueryCache, MutationCache, QueryClient } from "@tanstack/react-query";

new QueryClient({
  queryCache: new QueryCache({
    onError: (error, query) => {
      console.error(`Query failed [${query.queryHash}]:`, error);
      // toast.error("Something went wrong") — one place, not fifty
    },
  }),
  mutationCache: new MutationCache({
    onError: (error) => {
      console.error("Mutation failed:", error);
    },
  }),
});

3. Wrap queries in custom hooks

TS
// hooks/usePosts.ts
import { useQuery } from "@tanstack/react-query";
import { getPosts } from "@/lib/getPosts";
import { queryKeys } from "@/lib/queryKeys";

export function usePosts() {
  return useQuery({ queryKey: queryKeys.posts, queryFn: getPosts });
}

Components stay dumb; keys, fetchers, and options each live in one file.

4. Prefetch on intent, not just on mount

TSX
import Link from "next/link";
import { useQueryClient } from "@tanstack/react-query";

function PostLink({ postId }: { postId: number }) {
  const queryClient = useQueryClient();

  return (
    <Link
      href={`/posts/${postId}`}
      onMouseEnter={() =>
        queryClient.prefetchQuery({
          queryKey: queryKeys.post(postId),
          queryFn: () => getPostById(postId),
          staleTime: 60 * 1000,
        })
      }
    >
      View post {postId}
    </Link>
  );
}

Hover-prefetching makes navigations feel instant for almost zero cost.

When you don't need TanStack Query

Honest caveat, because not everything is server state:

  • Truly static content — a marketing page or docs site is better served by plain Server Components and static generation.
  • One-shot fetches — a contact form that POSTs once and shows a toast doesn't need a cache.
  • URL-driven state — search params and filters that belong in the URL belong in the URL.

If none of your data changes after load, TanStack Query is overhead. If it does, it's the highest-leverage dependency you'll add.

Key takeaways

  1. Server state ≠ client state. useState owns what the user typed; TanStack Query owns what the server said.
  2. Query keys are your cache schema — hierarchical, centralized, deliberate.
  3. Default to invalidation, optimize with optimistic updates where latency is felt (toggles, likes, carts).
  4. Hydrate from Server Components for fast first paint without giving up client-side reactivity.
  5. Set staleTime intentionally — it's the single knob controlling how "live" your app feels versus how many requests it makes.

Master these five, and data fetching stops being the hard part of your Next.js apps.

Muhammad Tahir logo

Muhammad Tahir

Building web & mobile apps since 2021. Passionate about clean code and real-world impact.