React Query with SSR: Mastering Data Prefetching in Next.js
2025-11-24

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 mounts3. 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:
- Prefetch critical, above-the-fold data on the server
- Use `HydrationBoundary` to pass dehydrated state to client
- Configure appropriate `staleTime` and `gcTime` values
- Prefetch queries in parallel when possible
- Use `ensureQueryData` for conditional prefetching
- Leverage TypeScript for type-safe queries and mutations
- Monitor cache size and implement garbage collection strategies
Don't:
- Prefetch everything—be selective about what data is critical
- Create QueryClient instances in Server Components without proper cleanup
- Ignore hydration mismatches—ensure server and client data consistency
- Forget to handle errors in prefetch functions
- Use React Query for truly static data (use Next.js static generation instead)
- 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.