If you're building modern web applications with Next.js, you know how crucial data management is. Fetching, caching, and updating data from a server is at the core of almost every application. This is where TanStack Query (formerly known as React Query) shines, especially with the introduction of the App Router in Next.js.
TanStack Query is not just a data-fetching library. It's a powerful server-state manager that gives you full control over asynchronous data without writing complex logic. Forget messy useState
and useEffect
hooks to manage loading, error, and data states.
In this guide, we'll dive deep into how to integrate TanStack Query in the Next.js App Router, from setup to practical examples.
Why TanStack Query?
Before we get into the implementation, let's understand why TanStack Query is a top choice for many developers:
- Smart Caching: Automatically caches data and serves it again when needed, making the application feel instantaneous.
- Background Refetching: Updates stale data in the background, ensuring users always see the most recent information.
- Automatic State Management: Manages
isLoading
,isError
,isSuccess
, anddata
states for you. - Optimistic Updates: Provides a super-fast user experience by updating the UI before the server response is received.
- Performance: Reduces the number of network requests with deduplication and caching.
With the App Router, where Server Components are first-class citizens, the way we manage client-side state has changed slightly. Let's set up our project.
Step 1: Installation
First, add TanStack Query to your Next.js project.
npm i @tanstack/react-query
Step 2: Setting Up the Provider for App Router
Unlike the Pages Router, which has a centralized _app.tsx
file, in the App Router, we need to create our own provider component that runs on the client-side ('use client'
).
Create a QueryClient Singleton
It's important to create only one instance of QueryClient
during the application's lifecycle so that the cache can be shared.
Create a new file at src/lib/query-client.ts
:
// src/lib/query-client.ts
import { QueryClient } from "@tanstack/react-query";
const queryClient = new QueryClient();
export default queryClient;
Create the Provider Component
Next, create a component that will provide the QueryClient
to your entire application.
Create a new file at src/components/shared/Providers.tsx
:
// src/components/shared/Providers.tsx
"use client";
import React from "react";
import { QueryClientProvider } from "@tanstack/react-query";
import queryClient from "@/lib/query-client";
function Providers({ children }: React.PropsWithChildren) {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}
export default Providers;
Integrate into the Root Layout
Finally, wrap your application with this Providers
component in app/layout.tsx
.
// app/layout.tsx
import Providers from "@/components/shared/Providers";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}
With that, our basic setup is complete! Now let's use it.
Step 3: Fetching Data with useQuery
useQuery
is the primary hook for fetching and caching data. Let's create an example to fetch a list of blog posts.
// app/blog/page.tsx
"use client";
import { useQuery } from "@tanstack/react-query";
async function getPosts() {
const res = await fetch("https://jsonplaceholder.typicode.com/posts");
if (!res.ok) {
throw new Error("Network response was not ok");
}
return res.json();
}
export default function BlogPage() {
const { data, isLoading, isError, error } = useQuery({
queryKey: ["posts"],
queryFn: getPosts,
});
if (isLoading) {
return <span>Loading...</span>;
}
if (isError) {
return <span>Error: {error.message}</span>;
}
return (
<ul>
{data.map((post: any) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
queryKey
: A unique key for this query. TanStack Query uses this for caching.queryFn
: An asynchronous function that returns the data.
Step 4: Handling Dynamic Params
What if we want to fetch a single post based on a slug
or id
from the URL? We can use useParams
from next/navigation
and include it in the queryKey
.
// app/blog/[id]/page.tsx
"use client";
import { useQuery } from "@tanstack/react-query";
import { useParams } from "next/navigation";
async function getPostById(id: string) {
const res = await fetch(`https://jsonplaceholder.typicode.com/posts/${id}`);
if (!res.ok) {
throw new Error("Network response was not ok");
}
return res.json();
}
export default function PostDetailPage() {
const params = useParams();
const { id } = params;
const { data, isLoading, isError } = useQuery({
// Add the `id` to the queryKey
queryKey: ["post", id],
queryFn: () => getPostById(id as string),
enabled: !!id, // Only run the query if the `id` is available
});
if (isLoading) return <div>Loading post...</div>;
if (isError) return <div>Failed to load post.</div>;
return (
<div>
<h1>{data.title}</h1>
<p>{data.body}</p>
</div>
);
}
By including the id
in the queryKey
, TanStack Query automatically differentiates the cache for each post page and fetches the correct data.
Step 5: Mutating Data with useMutation
For CREATE
, UPDATE
, or DELETE
operations, we use useMutation
. This hook gives us a mutate
function to trigger the operation.
Let's create an example to add a new post.
// app/blog/CreatePost.tsx
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import React, { useState } from "react";
async function createPost(newPost: { title: string; body: string }) {
const res = await fetch("https://jsonplaceholder.typicode.com/posts", {
method: "POST",
body: JSON.stringify(newPost),
headers: {
"Content-type": "application/json; charset=UTF-8",
},
});
return res.json();
}
export function CreatePostForm() {
const queryClient = useQueryClient();
const [title, setTitle] = useState("");
const mutation = useMutation({
mutationFn: createPost,
onSuccess: () => {
// Invalidate the 'posts' cache to refetch the post list
queryClient.invalidateQueries({ queryKey: ["posts"] });
console.log("Post created successfully!");
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
mutation.mutate({ title, body: "A new post body" });
};
return (
<form onSubmit={handleSubmit}>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Post title"
disabled={mutation.isPending}
/>
<button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? "Creating..." : "Create Post"}
</button>
{mutation.isError && <p>Error: {mutation.error.message}</p>}
</form>
);
}
The key to this example is queryClient.invalidateQueries({ queryKey: ['posts'] })
. After the mutation succeeds, we tell TanStack Query that the data associated with the ['posts']
key is now invalid. This will automatically trigger a refetch for any component using useQuery
with that key, keeping your UI perfectly in sync.
Conclusion
TanStack Query is an incredible tool for server-state management in Next.js applications. With the App Router, its integration remains simple and powerful. By handling caching, background updates, and state management automatically, you can focus on what matters most: building great features.
You have learned how to:
- Set up the
QueryClientProvider
for the App Router. - Fetch data with
useQuery
. - Handle dynamic parameters in the
queryKey
. - Create, update, or delete data with
useMutation
and keep the UI in sync.
By mastering these patterns, you are ready to build fast, reliable, and user-friendly Next.js applications.