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

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.
🚀 TL;DR
Use React Query only when:
- You need client-side state like infinite scroll, user-controlled refetching, or background updates
- Your data changes frequently and must be refreshed from the client
- You're already prefetching on the server, and want to hydrate on the client
Avoid React Query when:
- Data can be fully fetched server-side
- You don't need reactivity or fine-grained caching
- You're just rendering static or server-only content
🧠 Why This Combo Works (Sometimes)
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:
- Fast, cached UI from the server
- Client hydration and reactivity
- Reduced unnecessary network calls
🛠️ Basic Setup: React Query + Server Components
Let's walk through a basic working setup.
1. First, set up the React Query provider
// 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>
);
}
2. Add the provider to your layout
// 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>
);
}
3. Create a type-safe API client
// 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();
}
4. Create a Server Component that prefetches data
// 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>
);
}
5. Hydrate with React Query in a Client Component
// 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>
);
}
6. Create a product detail page with mutations
// 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.
🧪 What Happens Under the Hood?
- Next.js server components prefetch the data.
- You pass it as props to a client component.
- React Query hydrates with initialData, skipping the fetch.
- If you mutate or refetch on the client, React Query handles it.
📉 What Happens If You Don't Hydrate?
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.
⚠️ When Not to Use React Query
You don't need React Query when:
- You're rendering static content (e.g., blog posts)
- The data is fetched once at build time (SSG)
- There's no user interaction requiring refetches
In these cases, Server Components alone are faster, simpler, and reduce bundle size.
🧭 Decision Guide: Should You Use React Query?
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 | ✅ |
📦 Bonus: Use dehydrate() for Real Hydration
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>
);
}
🔚 Final Thoughts
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.
🙌 Enjoyed This Post?
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!