How to Use React Query with Next.js Server Components - Demo


Never miss an update
Subscribe to receive news and special offers.
By subscribing you agree to our Privacy Policy.
React Query is powerful for managing client-side data fetching. Next.js server components are great for optimizing initial page load and reducing client-side JavaScript. But should you use both together?
The short answer is: Yes—but only in the right places.
In this guide, you'll learn when and how to use React Query with server components for performance wins, and when you're better off skipping it entirely.
Use React Query only when:
Avoid React Query when:
Next.js Server Components allow you to render components on the server, eliminating the need to ship data-fetching logic to the browser.
React Query, on the other hand, excels at managing client-side caching, background updates, and async state.
So how do they fit together?
By prefetching data on the server and then hydrating React Query on the client, you get the best of both:
Let's walk through a basic working setup.
// app/providers.tsx
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { useState } from 'react';
export default function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 minute
refetchOnWindowFocus: false,
},
},
})
);
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}// app/layout.tsx
import Providers from './providers';
import './globals.css';
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}// lib/api-client.ts
export type Product = {
id: number;
name: string;
price: number;
description: string;
category: string;
image: string;
rating: {
rate: number;
count: number;
};
};
export async function getProducts(): Promise<Product[]> {
const res = await fetch('https://fakestoreapi.com/products', {
next: { revalidate: 3600 }, // Revalidate every hour
});
if (!res.ok) {
throw new Error('Failed to fetch products');
}
return res.json();
}
export async function getProduct(id: number): Promise<Product> {
const res = await fetch(`https://fakestoreapi.com/products/${id}`, {
next: { revalidate: 3600 },
});
if (!res.ok) {
throw new Error(`Failed to fetch product with id ${id}`);
}
return res.json();
}// app/products/page.tsx
import { getProducts } from '@/lib/api-client';
import ProductList from '@/components/ProductList';
export default async function ProductsPage() {
// This runs on the server
const products = await getProducts();
return (
<div className="container mx-auto py-8">
<h1 className="text-3xl font-bold mb-6">Products</h1>
<ProductList initialProducts={products} />
</div>
);
}// components/ProductList.tsx
'use client';
import { useQuery } from '@tanstack/react-query';
import { getProducts, Product } from '@/lib/api-client';
import Image from 'next/image';
import Link from 'next/link';
export default function ProductList({
initialProducts
}: {
initialProducts: Product[]
}) {
// This uses the data from the server as initialData
const { data: products, isLoading, error } = useQuery({
queryKey: ['products'],
queryFn: getProducts,
initialData: initialProducts,
staleTime: 10 * 60 * 1000, // 10 minutes
});
if (error) {
return <div className="text-red-500">Error loading products: {error.message}</div>;
}
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{products.map((product) => (
<div
key={product.id}
className="border rounded-lg overflow-hidden shadow-md hover:shadow-lg transition-shadow"
>
<div className="relative h-48 bg-gray-100">
<Image
src={product.image}
alt={product.name}
fill
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
style={{ objectFit: 'contain' }}
priority={product.id <= 4} // Prioritize loading first 4 images
/>
</div>
<div className="p-4">
<h2 className="text-lg font-semibold truncate">{product.name}</h2>
<p className="text-gray-600 mt-1">${product.price.toFixed(2)}</p>
<div className="flex items-center mt-2">
<span className="text-yellow-500">★</span>
<span className="ml-1">{product.rating.rate} ({product.rating.count})</span>
</div>
<Link
href={`/products/${product.id}`}
className="block mt-3 text-center bg-blue-600 text-white py-2 rounded hover:bg-blue-700 transition-colors"
>
View Details
</Link>
</div>
</div>
))}
</div>
);
}// app/products/[id]/page.tsx
import { getProduct } from '@/lib/api-client';
import ProductDetail from '@/components/ProductDetail';
import { notFound } from 'next/navigation';
export default async function ProductPage({
params
}: {
params: { id: string }
}) {
try {
const productId = parseInt(params.id);
const product = await getProduct(productId);
return <ProductDetail initialProduct={product} />;
} catch (error) {
notFound();
}
}// components/ProductDetail.tsx
'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getProduct, Product } from '@/lib/api-client';
import Image from 'next/image';
async function addToCart(productId: number, quantity: number) {
// In a real app, this would be an API call
const response = await fetch('/api/cart', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ productId, quantity }),
});
if (!response.ok) {
throw new Error('Failed to add to cart');
}
return response.json();
}
export default function ProductDetail({
initialProduct
}: {
initialProduct: Product
}) {
const [quantity, setQuantity] = useState(1);
const queryClient = useQueryClient();
const { data: product } = useQuery({
queryKey: ['product', initialProduct.id],
queryFn: () => getProduct(initialProduct.id),
initialData: initialProduct,
staleTime: 5 * 60 * 1000, // 5 minutes
});
const addToCartMutation = useMutation({
mutationFn: () => addToCart(product.id, quantity),
onSuccess: () => {
// Invalidate cart queries to refresh cart data
queryClient.invalidateQueries({ queryKey: ['cart'] });
setQuantity(1);
},
});
return (
<div className="container mx-auto py-8">
<div className="flex flex-col md:flex-row gap-8">
<div className="md:w-1/2 relative h-[400px] bg-white border rounded-lg">
<Image
src={product.image}
alt={product.name}
fill
sizes="(max-width: 768px) 100vw, 50vw"
style={{ objectFit: 'contain' }}
priority
/>
</div>
<div className="md:w-1/2">
<h1 className="text-3xl font-bold">{product.name}</h1>
<div className="flex items-center mt-2 mb-4">
<span className="text-yellow-500">★</span>
<span className="ml-1">{product.rating.rate} ({product.rating.count} reviews)</span>
</div>
<p className="text-2xl font-semibold my-4">${product.price.toFixed(2)}</p>
<p className="text-gray-700 mb-6">{product.description}</p>
<div className="flex items-center mb-6">
<label htmlFor="quantity" className="mr-4">Quantity:</label>
<select
id="quantity"
value={quantity}
onChange={(e) => setQuantity(parseInt(e.target.value))}
className="border rounded px-3 py-2"
>
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((num) => (
<option key={num} value={num}>{num}</option>
))}
</select>
</div>
<button
onClick={() => addToCartMutation.mutate()}
disabled={addToCartMutation.isPending}
className="w-full bg-blue-600 text-white py-3 rounded-lg hover:bg-blue-700 transition-colors disabled:bg-blue-400"
>
{addToCartMutation.isPending ? 'Adding...' : 'Add to Cart'}
</button>
{addToCartMutation.isError && (
<p className="text-red-500 mt-2">
Error: {addToCartMutation.error.message}
</p>
)}
{addToCartMutation.isSuccess && (
<p className="text-green-500 mt-2">
Product added to cart!
</p>
)}
</div>
</div>
</div>
);
}✅ The client won't re-fetch, thanks to initialData.
✅ You avoid network roundtrips.
✅ You still get client-side reactivity when needed.
If you skip the initialData, React Query will re-fetch on the client—even if you already fetched the same data on the server.
That's wasteful.
Always hydrate with the data you already have.
You don't need React Query when:
In these cases, Server Components alone are faster, simpler, and reduce bundle size.
| Use Case | Use React Query? |
|---|---|
| Static marketing page | ❌ |
| Dashboard with live data | ✅ |
| Paginated or infinite scroll | ✅ |
| Build-time static data (SSG) | ❌ |
| Real-time chat or mutation-heavy | ✅ |
For more complex setups (pagination, nested queries), use React Query's dehydrate()/Hydrate utilities:
// app/products/page.tsx
import { dehydrate, HydrationBoundary } from '@tanstack/react-query';
import { getProducts } from '@/lib/api-client';
import ProductList from '@/components/ProductList';
import getQueryClient from '@/lib/getQueryClient';
export default async function ProductsPage() {
const queryClient = getQueryClient();
// Prefetch the data on the server
await queryClient.prefetchQuery({
queryKey: ['products'],
queryFn: getProducts,
});
// Dehydrate the cache
const dehydratedState = dehydrate(queryClient);
return (
<div className="container mx-auto py-8">
<h1 className="text-3xl font-bold mb-6">Products</h1>
<HydrationBoundary state={dehydratedState}>
<ProductList />
</HydrationBoundary>
</div>
);
}// lib/getQueryClient.ts
import { QueryClient } from '@tanstack/react-query';
import { cache } from 'react';
// Create a new QueryClient instance for each request
const getQueryClient = cache(() => new QueryClient());
export default getQueryClient;// components/ProductList.tsx (modified for HydrationBoundary)
'use client';
import { useQuery } from '@tanstack/react-query';
import { getProducts, Product } from '@/lib/api-client';
import Image from 'next/image';
import Link from 'next/link';
export default function ProductList() {
// No need for initialProducts anymore
const { data: products, isLoading, error } = useQuery({
queryKey: ['products'],
queryFn: getProducts,
});
if (isLoading) {
return <div>Loading products...</div>;
}
if (error) {
return <div className="text-red-500">Error loading products: {error.message}</div>;
}
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{products.map((product) => (
<div
key={product.id}
className="border rounded-lg overflow-hidden shadow-md hover:shadow-lg transition-shadow"
>
{/* Same component body as before */}
</div>
))}
</div>
);
}React Query and server components don't compete—they complement each other when used thoughtfully.
Use server components for static or simple data. Layer in React Query when the client needs interactivity, updates, or control.
This hybrid approach leads to better performance and a better developer experience.
If this helped you understand React Query and Next.js better, share it or star the GitHub repo.
Need help integrating React Query in your app? Let's connect!