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
1// app/providers.tsx
2'use client';
3
4import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
5import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
6import { useState } from 'react';
7
8export default function Providers({ children }: { children: React.ReactNode }) {
9 const [queryClient] = useState(
10 () =>
11 new QueryClient({
12 defaultOptions: {
13 queries: {
14 staleTime: 60 * 1000, // 1 minute
15 refetchOnWindowFocus: false,
16 },
17 },
18 })
19 );
20
21 return (
22 <QueryClientProvider client={queryClient}>
23 {children}
24 <ReactQueryDevtools initialIsOpen={false} />
25 </QueryClientProvider>
26 );
27}
2. Add the provider to your layout
1// app/layout.tsx
2import Providers from './providers';
3import './globals.css';
4
5export default function RootLayout({
6 children,
7}: {
8 children: React.ReactNode;
9}) {
10 return (
11 <html lang="en">
12 <body>
13 <Providers>{children}</Providers>
14 </body>
15 </html>
16 );
17}
3. Create a type-safe API client
1// lib/api-client.ts
2export type Product = {
3 id: number;
4 name: string;
5 price: number;
6 description: string;
7 category: string;
8 image: string;
9 rating: {
10 rate: number;
11 count: number;
12 };
13};
14
15export async function getProducts(): Promise<Product[]> {
16 const res = await fetch('https://fakestoreapi.com/products', {
17 next: { revalidate: 3600 }, // Revalidate every hour
18 });
19
20 if (!res.ok) {
21 throw new Error('Failed to fetch products');
22 }
23
24 return res.json();
25}
26
27export async function getProduct(id: number): Promise<Product> {
28 const res = await fetch(`https://fakestoreapi.com/products/${id}`, {
29 next: { revalidate: 3600 },
30 });
31
32 if (!res.ok) {
33 throw new Error(`Failed to fetch product with id ${id}`);
34 }
35
36 return res.json();
37}
4. Create a Server Component that prefetches data
1// app/products/page.tsx
2import { getProducts } from '@/lib/api-client';
3import ProductList from '@/components/ProductList';
4
5export default async function ProductsPage() {
6 // This runs on the server
7 const products = await getProducts();
8
9 return (
10 <div className="container mx-auto py-8">
11 <h1 className="text-3xl font-bold mb-6">Products</h1>
12 <ProductList initialProducts={products} />
13 </div>
14 );
15}
5. Hydrate with React Query in a Client Component
1// components/ProductList.tsx
2'use client';
3
4import { useQuery } from '@tanstack/react-query';
5import { getProducts, Product } from '@/lib/api-client';
6import Image from 'next/image';
7import Link from 'next/link';
8
9export default function ProductList({
10 initialProducts
11}: {
12 initialProducts: Product[]
13}) {
14 // This uses the data from the server as initialData
15 const { data: products, isLoading, error } = useQuery({
16 queryKey: ['products'],
17 queryFn: getProducts,
18 initialData: initialProducts,
19 staleTime: 10 * 60 * 1000, // 10 minutes
20 });
21
22 if (error) {
23 return <div className="text-red-500">Error loading products: {error.message}</div>;
24 }
25
26 return (
27 <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
28 {products.map((product) => (
29 <div
30 key={product.id}
31 className="border rounded-lg overflow-hidden shadow-md hover:shadow-lg transition-shadow"
32 >
33 <div className="relative h-48 bg-gray-100">
34 <Image
35 src={product.image}
36 alt={product.name}
37 fill
38 sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
39 style={{ objectFit: 'contain' }}
40 priority={product.id <= 4} // Prioritize loading first 4 images
41 />
42 </div>
43 <div className="p-4">
44 <h2 className="text-lg font-semibold truncate">{product.name}</h2>
45 <p className="text-gray-600 mt-1">${product.price.toFixed(2)}</p>
46 <div className="flex items-center mt-2">
47 <span className="text-yellow-500">★</span>
48 <span className="ml-1">{product.rating.rate} ({product.rating.count})</span>
49 </div>
50 <Link
51 href={`/products/${product.id}`}
52 className="block mt-3 text-center bg-blue-600 text-white py-2 rounded hover:bg-blue-700 transition-colors"
53 >
54 View Details
55 </Link>
56 </div>
57 </div>
58 ))}
59 </div>
60 );
61}
6. Create a product detail page with mutations
1// app/products/[id]/page.tsx
2import { getProduct } from '@/lib/api-client';
3import ProductDetail from '@/components/ProductDetail';
4import { notFound } from 'next/navigation';
5
6export default async function ProductPage({
7 params
8}: {
9 params: { id: string }
10}) {
11 try {
12 const productId = parseInt(params.id);
13 const product = await getProduct(productId);
14
15 return <ProductDetail initialProduct={product} />;
16 } catch (error) {
17 notFound();
18 }
19}
1// components/ProductDetail.tsx
2'use client';
3
4import { useState } from 'react';
5import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
6import { getProduct, Product } from '@/lib/api-client';
7import Image from 'next/image';
8
9async function addToCart(productId: number, quantity: number) {
10 // In a real app, this would be an API call
11 const response = await fetch('/api/cart', {
12 method: 'POST',
13 headers: { 'Content-Type': 'application/json' },
14 body: JSON.stringify({ productId, quantity }),
15 });
16
17 if (!response.ok) {
18 throw new Error('Failed to add to cart');
19 }
20
21 return response.json();
22}
23
24export default function ProductDetail({
25 initialProduct
26}: {
27 initialProduct: Product
28}) {
29 const [quantity, setQuantity] = useState(1);
30 const queryClient = useQueryClient();
31
32 const { data: product } = useQuery({
33 queryKey: ['product', initialProduct.id],
34 queryFn: () => getProduct(initialProduct.id),
35 initialData: initialProduct,
36 staleTime: 5 * 60 * 1000, // 5 minutes
37 });
38
39 const addToCartMutation = useMutation({
40 mutationFn: () => addToCart(product.id, quantity),
41 onSuccess: () => {
42 // Invalidate cart queries to refresh cart data
43 queryClient.invalidateQueries({ queryKey: ['cart'] });
44 setQuantity(1);
45 },
46 });
47
48 return (
49 <div className="container mx-auto py-8">
50 <div className="flex flex-col md:flex-row gap-8">
51 <div className="md:w-1/2 relative h-[400px] bg-white border rounded-lg">
52 <Image
53 src={product.image}
54 alt={product.name}
55 fill
56 sizes="(max-width: 768px) 100vw, 50vw"
57 style={{ objectFit: 'contain' }}
58 priority
59 />
60 </div>
61
62 <div className="md:w-1/2">
63 <h1 className="text-3xl font-bold">{product.name}</h1>
64 <div className="flex items-center mt-2 mb-4">
65 <span className="text-yellow-500">★</span>
66 <span className="ml-1">{product.rating.rate} ({product.rating.count} reviews)</span>
67 </div>
68
69 <p className="text-2xl font-semibold my-4">${product.price.toFixed(2)}</p>
70 <p className="text-gray-700 mb-6">{product.description}</p>
71
72 <div className="flex items-center mb-6">
73 <label htmlFor="quantity" className="mr-4">Quantity:</label>
74 <select
75 id="quantity"
76 value={quantity}
77 onChange={(e) => setQuantity(parseInt(e.target.value))}
78 className="border rounded px-3 py-2"
79 >
80 {[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((num) => (
81 <option key={num} value={num}>{num}</option>
82 ))}
83 </select>
84 </div>
85
86 <button
87 onClick={() => addToCartMutation.mutate()}
88 disabled={addToCartMutation.isPending}
89 className="w-full bg-blue-600 text-white py-3 rounded-lg hover:bg-blue-700 transition-colors disabled:bg-blue-400"
90 >
91 {addToCartMutation.isPending ? 'Adding...' : 'Add to Cart'}
92 </button>
93
94 {addToCartMutation.isError && (
95 <p className="text-red-500 mt-2">
96 Error: {addToCartMutation.error.message}
97 </p>
98 )}
99
100 {addToCartMutation.isSuccess && (
101 <p className="text-green-500 mt-2">
102 Product added to cart!
103 </p>
104 )}
105 </div>
106 </div>
107 </div>
108 );
109}
✅ 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:
1// app/products/page.tsx
2import { dehydrate, HydrationBoundary } from '@tanstack/react-query';
3import { getProducts } from '@/lib/api-client';
4import ProductList from '@/components/ProductList';
5import getQueryClient from '@/lib/getQueryClient';
6
7export default async function ProductsPage() {
8 const queryClient = getQueryClient();
9
10 // Prefetch the data on the server
11 await queryClient.prefetchQuery({
12 queryKey: ['products'],
13 queryFn: getProducts,
14 });
15
16 // Dehydrate the cache
17 const dehydratedState = dehydrate(queryClient);
18
19 return (
20 <div className="container mx-auto py-8">
21 <h1 className="text-3xl font-bold mb-6">Products</h1>
22 <HydrationBoundary state={dehydratedState}>
23 <ProductList />
24 </HydrationBoundary>
25 </div>
26 );
27}
1// lib/getQueryClient.ts
2import { QueryClient } from '@tanstack/react-query';
3import { cache } from 'react';
4
5// Create a new QueryClient instance for each request
6const getQueryClient = cache(() => new QueryClient());
7export default getQueryClient;
1// components/ProductList.tsx (modified for HydrationBoundary)
2'use client';
3
4import { useQuery } from '@tanstack/react-query';
5import { getProducts, Product } from '@/lib/api-client';
6import Image from 'next/image';
7import Link from 'next/link';
8
9export default function ProductList() {
10 // No need for initialProducts anymore
11 const { data: products, isLoading, error } = useQuery({
12 queryKey: ['products'],
13 queryFn: getProducts,
14 });
15
16 if (isLoading) {
17 return <div>Loading products...</div>;
18 }
19
20 if (error) {
21 return <div className="text-red-500">Error loading products: {error.message}</div>;
22 }
23
24 return (
25 <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
26 {products.map((product) => (
27 <div
28 key={product.id}
29 className="border rounded-lg overflow-hidden shadow-md hover:shadow-lg transition-shadow"
30 >
31 {/* Same component body as before */}
32 </div>
33 ))}
34 </div>
35 );
36}
🔚 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!