🚀 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.