logo

React Query with SSR: Mastering Data Prefetching in Next.js

2025-11-24

react
react-query
tanstack-query
next.js
ssr
data-fetching
prefetching
hydration
performance
server-components
typescript
frontend
React Query with SSR: Mastering Data Prefetching in Next.js

React Query (TanStack Query) transforms how we handle server state in React applications. When combined with Next.js SSR, prefetching becomes a powerful tool for delivering instant, cached data to users. This guide explores practical patterns for prefetching queries on the server, hydrating them on the client, and maintaining optimal performance.

What you'll learn:

  • How to set up React Query with Next.js App Router and Server Components
  • Prefetching strategies: getServerSideProps, Server Actions, and Route Handlers
  • Hydration patterns and avoiding duplicate requests
  • Advanced techniques: infinite queries, mutations, and optimistic updates with SSR
  • Performance optimization: stale-while-revalidate, background refetching, and cache management
  • Real-world patterns for authentication, pagination, and real-time data

Why React Query + SSR?

React Query solves a fundamental problem: managing server state in client applications. It provides caching, background updates, and optimistic updates out of the box. When combined with Next.js SSR, you get the best of both worlds: fast initial page loads with server-rendered content, and seamless client-side data management.

The key advantage is prefetching: you can fetch data on the server, render it immediately, and then hydrate that same data into React Query's cache on the client. This eliminates loading states, reduces waterfall requests, and provides instant interactivity.

Benefits of React Query + SSR:

  • Zero loading states on initial render (data is already there)
  • Automatic background refetching keeps data fresh
  • Optimistic updates work seamlessly with server state
  • Built-in request deduplication prevents duplicate API calls
  • Powerful devtools for debugging and cache inspection
  • TypeScript-first design with excellent type inference

Setting Up React Query with Next.js

First, let's set up the QueryClient provider. In Next.js App Router, we need to create a client-side provider since React Query hooks can only run on the client.

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 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 gcTime: 5 * 60 * 1000, // 5 minutes (formerly cacheTime) 16 refetchOnWindowFocus: false, 17 retry: 1, 18 }, 19 }, 20 }) 21 ); 22 23 return ( 24 <QueryClientProvider client={queryClient}> 25 {children} 26 <ReactQueryDevtools initialIsOpen={false} /> 27 </QueryClientProvider> 28 ); 29}

Root Layout Integration

Then, wrap your root layout with the Providers component:

1// app/layout.tsx 2import { Providers } from './providers'; 3 4export default function RootLayout({ 5 children, 6}: { 7 children: React.ReactNode; 8}) { 9 return ( 10 <html lang="en"> 11 <body> 12 <Providers>{children}</Providers> 13 </body> 14 </html> 15 ); 16}

Prefetching in Server Components

In Next.js App Router, Server Components can prefetch data directly. We'll use React Query's `HydrationBoundary` to pass prefetched data to the client.

1// app/posts/page.tsx 2import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query'; 3import { getPosts } from '@/lib/api'; 4import PostsList from './PostsList'; 5 6export default async function PostsPage() { 7 const queryClient = new QueryClient(); 8 9 // Prefetch the data on the server 10 await queryClient.prefetchQuery({ 11 queryKey: ['posts'], 12 queryFn: getPosts, 13 }); 14 15 return ( 16 <HydrationBoundary state={dehydrate(queryClient)}> 17 <PostsList /> 18 </HydrationBoundary> 19 ); 20}

Client Component Integration

The `PostsList` component can now use `useQuery` without making an initial request—the data is already in the cache:

1// app/posts/PostsList.tsx 2'use client'; 3 4import { useQuery } from '@tanstack/react-query'; 5import { getPosts } from '@/lib/api'; 6 7export default function PostsList() { 8 const { data, isLoading, error } = useQuery({ 9 queryKey: ['posts'], 10 queryFn: getPosts, 11 }); 12 13 if (isLoading) return <div>Loading...</div>; 14 if (error) return <div>Error: {error.message}</div>; 15 16 return ( 17 <div> 18 {data?.map((post) => ( 19 <article key={post.id}> 20 <h2>{post.title}</h2> 21 <p>{post.excerpt}</p> 22 </article> 23 ))} 24 </div> 25 ); 26}

Prefetching with Route Handlers

For more control, you can prefetch data in Route Handlers and pass it to Server Components. This is useful when you need to handle authentication or complex data transformations on the server.

1// app/api/posts/route.ts 2import { NextResponse } from 'next/server'; 3import { QueryClient, dehydrate } from '@tanstack/react-query'; 4import { getPosts } from '@/lib/api'; 5 6export async function GET() { 7 const queryClient = new QueryClient(); 8 9 await queryClient.prefetchQuery({ 10 queryKey: ['posts'], 11 queryFn: getPosts, 12 }); 13 14 const dehydratedState = dehydrate(queryClient); 15 16 return NextResponse.json({ 17 posts: dehydratedState.queries[0]?.state?.data, 18 dehydratedState, 19 }); 20}

Server Component Usage

Then use it in your Server Component:

1// app/posts/page.tsx 2import { HydrationBoundary, dehydrate, QueryClient } from '@tanstack/react-query'; 3import PostsList from './PostsList'; 4 5async function getPrefetchedData() { 6 const res = await fetch('http://localhost:3000/api/posts', { 7 cache: 'no-store', // or 'force-cache' for static generation 8 }); 9 const { dehydratedState } = await res.json(); 10 11 const queryClient = new QueryClient(); 12 queryClient.setState(dehydratedState); 13 14 return dehydrate(queryClient); 15} 16 17export default async function PostsPage() { 18 const dehydratedState = await getPrefetchedData(); 19 20 return ( 21 <HydrationBoundary state={dehydratedState}> 22 <PostsList /> 23 </HydrationBoundary> 24 ); 25}

Advanced Patterns: Infinite Queries

React Query's `useInfiniteQuery` is perfect for pagination. Here's how to prefetch infinite queries on the server:

1// app/posts/page.tsx 2import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query'; 3import { getPostsPaginated } from '@/lib/api'; 4import InfinitePostsList from './InfinitePostsList'; 5 6export default async function PostsPage() { 7 const queryClient = new QueryClient(); 8 9 // Prefetch the first page 10 await queryClient.prefetchInfiniteQuery({ 11 queryKey: ['posts', 'infinite'], 12 queryFn: ({ pageParam = 1 }) => getPostsPaginated({ page: pageParam }), 13 initialPageParam: 1, 14 }); 15 16 return ( 17 <HydrationBoundary state={dehydrate(queryClient)}> 18 <InfinitePostsList /> 19 </HydrationBoundary> 20 ); 21}

Client-Side Infinite List

The client component uses `useInfiniteQuery` with automatic pagination:

1// app/posts/InfinitePostsList.tsx 2'use client'; 3 4import { useInfiniteQuery } from '@tanstack/react-query'; 5import { getPostsPaginated } from '@/lib/api'; 6import { useCallback } from 'react'; 7 8export default function InfinitePostsList() { 9 const { 10 data, 11 fetchNextPage, 12 hasNextPage, 13 isFetchingNextPage, 14 } = useInfiniteQuery({ 15 queryKey: ['posts', 'infinite'], 16 queryFn: ({ pageParam }) => getPostsPaginated({ page: pageParam }), 17 initialPageParam: 1, 18 getNextPageParam: (lastPage) => lastPage.nextPage ?? undefined, 19 }); 20 21 const posts = data?.pages.flatMap((page) => page.posts) ?? []; 22 23 return ( 24 <div> 25 {posts.map((post) => ( 26 <article key={post.id}> 27 <h2>{post.title}</h2> 28 </article> 29 ))} 30 {hasNextPage && ( 31 <button 32 onClick={() => fetchNextPage()} 33 disabled={isFetchingNextPage} 34 > 35 {isFetchingNextPage ? 'Loading...' : 'Load More'} 36 </button> 37 )} 38 </div> 39 ); 40}

Optimistic Updates with SSR

Optimistic updates work seamlessly with prefetched data. Here's a pattern for mutations that update the cache immediately:

1// app/posts/PostActions.tsx 2'use client'; 3 4import { useMutation, useQueryClient } from '@tanstack/react-query'; 5import { updatePost } from '@/lib/api'; 6 7export default function PostActions({ postId }: { postId: string }) { 8 const queryClient = useQueryClient(); 9 10 const mutation = useMutation({ 11 mutationFn: updatePost, 12 onMutate: async (newPost) => { 13 // Cancel outgoing refetches 14 await queryClient.cancelQueries({ queryKey: ['post', postId] }); 15 16 // Snapshot previous value 17 const previousPost = queryClient.getQueryData(['post', postId]); 18 19 // Optimistically update 20 queryClient.setQueryData(['post', postId], newPost); 21 22 return { previousPost }; 23 }, 24 onError: (err, newPost, context) => { 25 // Rollback on error 26 queryClient.setQueryData(['post', postId], context?.previousPost); 27 }, 28 onSettled: () => { 29 // Refetch to ensure consistency 30 queryClient.invalidateQueries({ queryKey: ['post', postId] }); 31 }, 32 }); 33 34 return ( 35 <button 36 onClick={() => mutation.mutate({ id: postId, title: 'New Title' })} 37 disabled={mutation.isPending} 38 > 39 {mutation.isPending ? 'Updating...' : 'Update Post'} 40 </button> 41 ); 42}

Performance Optimization Strategies

1. Stale-While-Revalidate

Configure `staleTime` to control how long data is considered fresh. During this time, React Query won't refetch even if the component remounts.

1const queryClient = new QueryClient({ 2 defaultOptions: { 3 queries: { 4 staleTime: 5 * 60 * 1000, // 5 minutes 5 // Data is fresh for 5 minutes, then becomes stale 6 // Stale data is still shown while refetching in background 7 }, 8 }, 9});

2. Selective Prefetching

Only prefetch critical data. Use `prefetchQuery` for above-the-fold content and let below-the-fold content load on demand.

1// Prefetch only critical data 2await queryClient.prefetchQuery({ 3 queryKey: ['user', userId], 4 queryFn: () => getUser(userId), 5}); 6 7// Let comments load on demand 8// They'll be fetched when CommentsList mounts

3. Parallel Prefetching

Prefetch multiple queries in parallel to reduce server response time:

1export default async function DashboardPage() { 2 const queryClient = new QueryClient(); 3 4 // Prefetch in parallel 5 await Promise.all([ 6 queryClient.prefetchQuery({ 7 queryKey: ['user'], 8 queryFn: getCurrentUser, 9 }), 10 queryClient.prefetchQuery({ 11 queryKey: ['notifications'], 12 queryFn: getNotifications, 13 }), 14 queryClient.prefetchQuery({ 15 queryKey: ['stats'], 16 queryFn: getStats, 17 }), 18 ]); 19 20 return ( 21 <HydrationBoundary state={dehydrate(queryClient)}> 22 <Dashboard /> 23 </HydrationBoundary> 24 ); 25}

Common Pitfalls and Solutions

Pitfall 1: QueryClient Instance

  • Problem: Creating a new QueryClient on every server request can cause memory leaks.
  • Solution: Create QueryClient per request, but ensure proper cleanup. In Next.js, each request gets its own instance automatically.

Pitfall 2: Hydration Mismatches

  • Problem: Server-rendered data doesn't match client expectations, causing hydration errors.
  • Solution: Ensure server and client use the same query keys and data structures. Use `ensureQueryData` for conditional prefetching.

Pitfall 3: Over-prefetching

  • Problem: Prefetching too much data increases server response time and memory usage.
  • Solution: Only prefetch critical, above-the-fold data. Use code splitting and lazy loading for secondary content.
1// Good: Conditional prefetching 2export default async function PostPage({ params }: { params: { id: string } }) { 3 const queryClient = new QueryClient(); 4 5 // Only prefetch if not already in cache 6 await queryClient.ensureQueryData({ 7 queryKey: ['post', params.id], 8 queryFn: () => getPost(params.id), 9 }); 10 11 return ( 12 <HydrationBoundary state={dehydrate(queryClient)}> 13 <PostContent id={params.id} /> 14 </HydrationBoundary> 15 ); 16}

Real-World Example: Authentication Flow

Here's a complete example of prefetching user data with authentication:

1// lib/api.ts 2export async function getCurrentUser() { 3 const token = cookies().get('auth-token')?.value; 4 if (!token) throw new Error('Unauthorized'); 5 6 const res = await fetch('https://api.example.com/user', { 7 headers: { Authorization: `Bearer ${token}` }, 8 }); 9 10 if (!res.ok) throw new Error('Failed to fetch user'); 11 return res.json(); 12} 13 14// app/dashboard/page.tsx 15import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query'; 16import { getCurrentUser } from '@/lib/api'; 17import { redirect } from 'next/navigation'; 18import Dashboard from './Dashboard'; 19 20export default async function DashboardPage() { 21 const queryClient = new QueryClient(); 22 23 try { 24 await queryClient.prefetchQuery({ 25 queryKey: ['user'], 26 queryFn: getCurrentUser, 27 }); 28 } catch (error) { 29 // Redirect if unauthorized 30 redirect('/login'); 31 } 32 33 return ( 34 <HydrationBoundary state={dehydrate(queryClient)}> 35 <Dashboard /> 36 </HydrationBoundary> 37 ); 38}

Best Practices Summary

Do:

  1. Prefetch critical, above-the-fold data on the server
  2. Use `HydrationBoundary` to pass dehydrated state to client
  3. Configure appropriate `staleTime` and `gcTime` values
  4. Prefetch queries in parallel when possible
  5. Use `ensureQueryData` for conditional prefetching
  6. Leverage TypeScript for type-safe queries and mutations
  7. Monitor cache size and implement garbage collection strategies

Don't:

  1. Prefetch everything—be selective about what data is critical
  2. Create QueryClient instances in Server Components without proper cleanup
  3. Ignore hydration mismatches—ensure server and client data consistency
  4. Forget to handle errors in prefetch functions
  5. Use React Query for truly static data (use Next.js static generation instead)
  6. Over-fetch data—only request what's needed for the initial render

Conclusion

React Query with Next.js SSR provides a powerful combination for building fast, interactive applications. By prefetching data on the server and hydrating it on the client, you eliminate loading states and provide instant user experiences.

The key is finding the right balance: prefetch critical data, let non-critical data load on demand, and leverage React Query's caching and background updates to keep everything fresh.

Start with simple prefetching patterns and gradually add more sophisticated optimizations as you understand your application's data access patterns. The React Query DevTools are invaluable for debugging and understanding cache behavior.

Let's work together

I'm always excited to take on new challenges and collaborate on innovative projects.

About Me

I'm a senior software engineer focusing on frontend and full-stack development. I specialize in ReactJS, TypeScript, and Next.js, always seeking growth and new challenges.

© 2025, anasroud.com