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

npmixnpmix
18
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
// 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

tsx
// 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

typescript
// 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

tsx
// 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

tsx
// 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

tsx
// 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();
  }
}
tsx
// 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?

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

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.