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

AndryAndry Dina
React Query and Next.js Server Components diagram

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

tsx
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

tsx
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

typescript
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

tsx
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

tsx
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

tsx
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}
tsx
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?

  1. Next.js server components prefetch the data.
  2. You pass it as props to a client component.
  3. React Query hydrates with initialData, skipping the fetch.
  4. 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 CaseUse 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:

tsx
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}
tsx
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;
tsx
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}

Docs: TanStack Hydration

🔚 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!

Similar articles

Never miss an update

Subscribe to receive news and special offers.

By subscribing you agree to our Privacy Policy.