Logo

How I Integrated TanStack Query in a Next.js App — A Developer's Story

Learn how to integrate TanStack Query in your Next.js application, including setup, data fetching, mutations, and best practices

Salman Khan

5/22/2025 · 4 min read

TanStack Query with Next.js

🚀 How I Integrated TanStack Query in a Next.js App — A Developer’s Story

It was a quiet Thursday morning — the kind of day when you want to refactor something or finally integrate that library you’ve been meaning to try out. I’d heard great things about TanStack Query (formerly known as React Query), especially when dealing with server state in modern React apps. I had a fresh Next.js project on my machine, and I figured, why not?

Let me walk you through exactly how I set it up, what I ran into, and how I structured things.

🔍 What’s TanStack Query Anyway?

Think of it like this: unlike Redux or Zustand (which are great for client-side state), TanStack Query is all about server state — data that comes from your backend APIs.

So instead of fetching data manually with useEffect or writing a bunch of loading/error states yourself, TanStack Query handles it with:

  • Caching
  • Background refetching
  • Auto-retries
  • Mutations
  • And more…

It makes fetching and managing remote data feel React-y.

🛠️ Step 1: Installing TanStack Query

I started by installing the required dependency:

pnpm add @tanstack/react-query

Since I’m using TypeScript, I also added the ESLint plugin:

pnpm add -D @tanstack/eslint-plugin-query

🔧 Step 2: Creating the Provider

Now, TanStack Query needs a QueryClientProvider around your app. But with Next.js 13+ and its Server/Client Components model, I couldn’t just toss it into layout.tsx. It’s a client-side component.

So I did this:

// components/providers/tanstack-provider.tsx
"use client";

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

export function TanstackProvider({ children }: { children: ReactNode }) {
  const [queryClient] = useState(() => new QueryClient());

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

Then I updated my root layout:

// app/layout.tsx
import { TanstackProvider } from "@/components/providers/tanstack-provider";

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html>
      <body>
        <TanstackProvider>{children}</TanstackProvider>
      </body>
    </html>
  );
}

Boom. Setup done.

📡 Step 3: Fetching Data with useQuery

I wanted to fetch a list of users from a dummy API. Here’s how I structured it:

// app/server/users.ts
export async function getUsers() {
  const res = await fetch("https://jsonplaceholder.typicode.com/users");
  return res.json();
}

Then, on the client page:

// app/page.tsx
"use client";

import { useQuery } from "@tanstack/react-query";
import { getUsers } from "./server/users";

export default function HomePage() {
  const {
    data: users,
    isLoading,
    error,
  } = useQuery({
    queryKey: ["users"],
    queryFn: getUsers,
  });

  if (isLoading) return <p>Loading...</p>;
  if (error) return <p>Error loading users</p>;

  return (
    <div>
      {users.map((user: any) => (
        <div key={user.id}>{user.name}</div>
      ))}
    </div>
  );
}

It just worked. And the best part? The data was cached. So even if I navigated away and came back, no refetching unless I asked for it.

✏️ Step 4: Adding a Mutation

Next, I wanted to try a mutation — something like “Create User”. Of course, this was a mock setup (no real database), so I just logged it to the server:

// app/server/users.ts
"use server";

export async function createUser(user: { id: number; name: string }) {
  console.log("User created:", user);
}

Then I added a button to trigger the mutation:

// app/components/CreateUserButton.tsx
"use client";

import { useMutation, useQueryClient } from "@tanstack/react-query";
import { createUser } from "../server/users";

export default function CreateUserButton() {
  const queryClient = useQueryClient();

  const mutation = useMutation({
    mutationFn: createUser,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ["users"] });
    },
  });

  return (
    <button
      onClick={() => mutation.mutate({ id: Date.now(), name: "New User" })}
    >
      Create User
    </button>
  );
}

Now when I clicked the button, the server logged the new user data, and useQuery got re-triggered to refetch the users list.

🎯 Step 5: Handling Loading and Error States

This was refreshingly simple. TanStack Query gives you isLoading, isError, data, and error out of the box. No boilerplate. You just use them like this:

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

📁 A Quick Note on Folder Structure

Eventually, I separated server and client concerns into different folders:

app/
├── server/
│   └── users.ts  // Server-only functions
├── client/
│   └── users.ts  // Data fetching hooks (if needed)

TanStack works great with this pattern.

💭 Final Thoughts

Once I had TanStack Query running, it completely changed how I handled server data. Caching, mutations, error handling — it all felt like magic, and I didn’t need useEffect or messy state logic anymore.

If you’re working in a Next.js app and dealing with a lot of API data, TanStack Query might just be your new best friend.

Let me know if you’d like me to share the full repo structure or walk through more advanced features like pagination, prefetching, or optimistic updates. I’d love to dig into that next.