Implementing Infinite Scroll Streaming SSR in Next.js App Router with TanStack Query v5 prefetchInfiniteQuery and React 19 use()
If you've ever implemented infinite scroll purely on the client side, you've probably experienced this at least once: a skeleton flickering on initial page load, a waterfall of consecutive requests in the Network tab, and SEO crawlers seeing nothing but empty HTML. The moment comes when you think, "Can't the server pre-fill this?"
I started out surviving by passing only the first page from getServerSideProps in the Pages Router, but things changed when I met Next.js App Router + TanStack Query v5. Where before the client still had to send additional requests even after the server handed off data, now HTML streams to the client already populated with multiple pages from the server, and the client starts in a scrollable state immediately — without a single network request. The pages option of prefetchInfiniteQuery and React 19's use() API are the keys.
Before reading this: This is aimed at intermediate or above readers who have experience using
useInfiniteQuery. Familiarity with React Server Components, Suspense, and the basics of TanStack Query will make it much easier to follow. If you're new to these, checking out the TanStack Query official docs first is recommended.
Core Concepts
prefetchInfiniteQuery — The Server-Side Counterpart for Infinite Scroll
When using TanStack Query, prefetchQuery feels familiar, but things can stall when you need SSR for infinite scroll too. That's exactly what prefetchInfiniteQuery was made for. If useInfiniteQuery builds up pages one by one on the client, prefetchInfiniteQuery builds that structure ahead of time on the server.
await queryClient.prefetchInfiniteQuery({
queryKey: ['posts'],
queryFn: ({ pageParam }: { pageParam: number }) => fetchPosts(pageParam),
initialPageParam: 0,
getNextPageParam: (lastPage: PostsResponse) => lastPage.nextCursor ?? undefined,
pages: 3, // 서버에서 첫 3페이지를 순서대로 프리페치
})The pages: 3 option is the key here. Without it, only the first page is populated — with this option, as many pages as specified are fetched sequentially on the server and stacked in the cache. When useInfiniteQuery on the client connects with the same queryKey, it inherits the cache the server pre-filled and renders immediately without any network request.
getNextPageParam— A function that takes the current page response and computes thepageParamfor the next page.prefetchInfiniteQueryuses this function to get the next cursor each time it repeats fetching for thepagescount. If this function doesn't match between server and client, data duplication or gaps can occur, so be careful.
The full flow of data from server to client looks like this:
Server Component
└─ prefetchInfiniteQuery (N페이지 패칭)
└─ dehydrate(queryClient)
└─ HydrationBoundary (직렬화된 캐시 전달)
└─ useInfiniteQuery (캐시 소비 — 네트워크 요청 없음)
└─ 추가 스크롤 시 fetchNextPageUnderstanding this single flow makes the three patterns below read naturally.
React 19 use() — Reading a Promise Directly During Render
Honestly, when I first saw the use() hook, I thought "what kind of magic is this?" You can read a Promise inside a component without await?
use() debuted as an official API in React 19. Worth noting — it existed experimentally in React 18.x's Canary channel first, so if you tried it on version 18 and it behaved differently, that's why. You need React 19 to use it reliably.
// 서버 컴포넌트에서 Promise를 만들어 prop으로 내려보냄
export default function Page() {
const dataPromise = fetchPosts(0) // await하지 않는 것이 핵심
return (
<Suspense fallback={<Skeleton />}>
<PostListClient dataPromise={dataPromise} />
</Suspense>
)
}
// 클라이언트 컴포넌트에서 use()로 소비
'use client'
function PostListClient({ dataPromise }: { dataPromise: Promise<PostsResponse> }) {
const initialData = use(dataPromise) // resolve될 때까지 suspend
// ...
}When use() receives a Promise, React pauses rendering at the nearest Suspense boundary and resumes when the Promise resolves. Unlike regular hooks, it can be called inside conditionals and loops, giving you much more freedom in code structure.
Streaming SSR — Instead of sending HTML all at once, the server sends ready UI blocks sequentially. Each time a Suspense boundary resolves, that chunk streams to the client, so users see a partially complete page progressively rather than waiting for everything to load.
Practical Application
Pattern A: Explicit Prefetch + HydrationBoundary — Recommended for Production
This is the most stable pattern, proven in production. The Server Component prefetches data, serializes it, and hands it off to the Client Component. I started with just pages: 1, and the "oh, this pre-fills multiple pages" realization didn't hit me until I bumped it to pages: 3.
// app/posts/page.tsx (Server Component)
import { QueryClient, dehydrate, HydrationBoundary } from '@tanstack/react-query'
import { Suspense } from 'react'
import { PostList } from '@/components/PostList'
import { PostsSkeleton } from '@/components/PostsSkeleton'
import { fetchPosts } from '@/lib/api'
export default async function PostsPage() {
const queryClient = new QueryClient()
await queryClient.prefetchInfiniteQuery({
queryKey: ['posts'],
queryFn: ({ pageParam }: { pageParam: number }) => fetchPosts(pageParam),
initialPageParam: 0,
getNextPageParam: (last: PostsResponse) => last.nextCursor ?? undefined,
pages: 2, // 초기 2페이지를 서버에서 미리 채움
})
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<Suspense fallback={<PostsSkeleton />}>
<PostList />
</Suspense>
</HydrationBoundary>
)
}// components/PostList.tsx (Client Component)
'use client'
import { useEffect } from 'react'
import { useInfiniteQuery } from '@tanstack/react-query'
import { useInView } from 'react-intersection-observer'
import { fetchPosts } from '@/lib/api'
import type { PostsResponse } from '@/types'
export function PostList() {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteQuery({
queryKey: ['posts'],
queryFn: ({ pageParam }: { pageParam: number }) => fetchPosts(pageParam),
initialPageParam: 0,
getNextPageParam: (last: PostsResponse) => last.nextCursor ?? undefined,
staleTime: 60 * 1000, // 1분간 fresh 유지 — 페이지 이동 후 복귀 시 재요청 방지
})
const { ref, inView } = useInView()
useEffect(() => {
if (inView && hasNextPage) fetchNextPage()
}, [inView, hasNextPage, fetchNextPage])
return (
<>
{data?.pages.flatMap((p) => p.items).map((post) => (
<PostCard key={post.id} post={post} />
))}
<div ref={ref}>
{isFetchingNextPage && <Spinner />}
</div>
</>
)
}| Code Point | Description |
|---|---|
pages: 2 |
Fetches pages 0 and 1 sequentially on the server and loads them into the cache |
HydrationBoundary |
Serializes the server cache and restores it into the client QueryClient |
staleTime setting |
Prevents unnecessary refetching when returning after client-side navigation |
useInView + useEffect |
Triggers fetchNextPage on scroll detection — Intersection Observer based |
Pattern B: Promise Streaming + use() — Handling Slow Data Without Blocking
Useful when you have a slow initial data request and want to stream other UI first while the server waits for the response. I initially used this pattern as a drop-in replacement for Pattern A and ran into trouble — the two have different purposes. The key to this pattern is passing the Promise as-is without await, so the Server Component function itself doesn't need async.
// app/posts/page.tsx (Server Component)
// async 없음 — await하지 않고 Promise 자체를 클라이언트로 넘기는 것이 이 패턴의 핵심
export default function PostsPage() {
const dataPromise = fetchPosts(0) // Promise만 생성, 서버가 블로킹되지 않음
return (
<div>
<Header /> {/* 즉시 스트리밍 */}
<Suspense fallback={<PostsSkeleton />}>
<PostListClient dataPromise={dataPromise} />
</Suspense>
</div>
)
}// components/PostListClient.tsx (Client Component)
'use client'
import { use } from 'react'
import { useInfiniteQuery } from '@tanstack/react-query'
import type { PostsResponse } from '@/types'
interface Props {
dataPromise: Promise<PostsResponse>
}
export function PostListClient({ dataPromise }: Props) {
const initialData = use(dataPromise) // Promise resolve까지 suspend
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey: ['posts'],
queryFn: ({ pageParam }: { pageParam: number }) => fetchPosts(pageParam),
initialPageParam: 0,
initialData: {
pages: [initialData],
pageParams: [0],
},
getNextPageParam: (last: PostsResponse) => last.nextCursor ?? undefined,
})
// ... 나머지 렌더링 로직
}⚠️ Caveat with the initialData pattern — This structure, where the first page obtained via
use(dataPromise)is passed touseInfiniteQuery'sinitialData, bypasses TanStack Query's cache and Hydration mechanism. BecauseinitialDatais not registered in the query cache, stale/refetch behavior after navigating away and returning may differ from Pattern A. Use this only for the purpose of streaming slow data in a non-blocking way; if cache reuse matters, Pattern A is recommended.
Serialization constraint — When passing a Promise as a prop from server to client, React only supports serializable values (JSON-compatible types). Including class instances or functions in a Promise result will cause errors, so return only plain data objects.
Pattern C: ReactQueryStreamedHydration — Automation Without Manual Prefetching
If writing prefetchInfiniteQuery in every Server Component feels tedious, there's also the option of setting up ReactQueryStreamedHydration from the @tanstack/react-query-next-experimental package just once in your layout.
Note, however, that this package is currently experimental. The TanStack team is working on stabilizing it, but as of this writing, the timeline for it graduating to an official package is unconfirmed. Before introducing it to production, check the official release notes for the current status.
// app/layout.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryStreamedHydration } from '@tanstack/react-query-next-experimental'
// QueryClient는 컴포넌트 외부에서 한 번만 생성해야 합니다
const queryClient = new QueryClient()
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<ReactQueryStreamedHydration>
{children}
</ReactQueryStreamedHydration>
</QueryClientProvider>
)
}// 이후 어떤 Client Component에서든 수동 prefetch 없이 SSR + 스트리밍이 동작
'use client'
import { useSuspenseInfiniteQuery } from '@tanstack/react-query'
import type { PostsResponse } from '@/types'
export function PostList() {
const { data } = useSuspenseInfiniteQuery({
queryKey: ['posts'],
queryFn: ({ pageParam }: { pageParam: number }) => fetchPosts(pageParam),
initialPageParam: 0,
getNextPageParam: (last: PostsResponse) => last.nextCursor ?? undefined,
})
// ...
}The setup is simple enough to want to use as a default pattern, but when client-side navigation scenarios get complex, waterfalls can hide in nested useSuspenseQuery calls when Server Components don't re-execute. For complex pages, Pattern A is safer.
Also, once item counts start exceeding tens of thousands, a different problem emerges — DOM nodes become heavy. That's a separate story to handle with TanStack Virtual and virtualization, which I'll cover in the next post.
Pros and Cons Analysis
Advantages
| Item | Details |
|---|---|
| Initial load performance | Eliminates client request waterfalls by prefetching multiple pages on the server |
| SEO | Server-rendered HTML is exposed to search engine crawlers |
| Progressive streaming | UI is sent sequentially per Suspense boundary, improving perceived speed |
| Cache reuse | Client inherits the server cache as-is, with no duplicate network requests |
| use() flexibility | Can be called inside conditionals and loops, giving more freedom in code structure |
Disadvantages and Caveats
| Item | Details | Mitigation |
|---|---|---|
| Hydration mismatch | Errors when server and client render results don't match | Avoid non-deterministic data like dates or random values inside Suspense boundaries |
| initialData cache bypass | In Pattern B, initialData doesn't go through the query cache, causing different stale/refetch behavior |
Use Pattern A (HydrationBoundary) when cache reuse matters |
| Experimental package risk | ReactQueryStreamedHydration is not yet stable | Check release notes before production use; pair with Pattern A for complex pages |
| Promise serialization constraint | Class instances and functions cannot be passed in the use() pattern | Return only plain data objects (JSON-compatible) from Promises |
Hydration mismatch — An error that occurs when the HTML generated on the server differs from the DOM React re-renders on the client. It's commonly caused by time or random values differing between server and client. It can be addressed with the
suppressHydrationWarningattribute or by handling client-only values insideuseEffect.
The Most Common Mistakes in Practice
-
Using prefetch without setting
staleTime— I missed this once and was surprised to see refetches flooding in every time I navigated between pages after deployment. The cause is the server-filled cache being marked stale immediately after client mount, triggering an instant refetch. Setting at leaststaleTime: 60 * 1000is recommended. -
Mismatched
getNextPageParamlogic between server and client — Writing it in two separate places makes it easy for subtle differences in cursor calculation to creep in. Extracting thequeryOptionsobject into a shared module and importing it in both places cleanly avoids this problem. -
Over-relying on
ReactQueryStreamedHydration— The easy setup makes it tempting to use as the default pattern, but when client-navigation scenarios grow complex, hidden waterfalls tend to surface. For complex pages, the explicitprefetchInfiniteQuerypattern is more predictable.
Closing Thoughts
We looked at three patterns, and the selection criteria are simpler than you might think. If cache reuse and stale management matter, go with Pattern A. If the goal is non-blocking streaming of slow data, go with Pattern B. For simple pages or prototyping, start with Pattern C.
After switching to this combination, I was genuinely surprised when I saw the client's first request disappear in the Network tab. There's something unexpectedly impressive about a page opening with data already filled in where the skeleton used to flicker.
Three steps you can take right now:
- Start by attaching Pattern A to an existing
useInfiniteQuerycomponent. If you already have@tanstack/react-queryv5 installed, addingprefetchInfiniteQueryandHydrationBoundaryto the page's Server Component is all it takes — you can watch the client's first request disappear in the Network tab right away. - Adjust
staleTimeand thepagescount to experience the performance tradeoffs firsthand. Comparingpages: 1vspages: 3, andstaleTime: 0vsstaleTime: 60000in Lighthouse or the Chrome DevTools Network tab makes the meaning of each setting much clearer. - If you have pages with slow data, experiment with Pattern B's streaming approach. Create a Promise in the Server Component without
await, pass it down to a Client Component, and wrap it inSuspense— you'll be able to see the progressive rendering where the Header appears first and the list fills in after.
If you've already applied similar patterns or have had different trial-and-error experiences, I'd love to hear about them in the comments. As more experiences applying these in different situations accumulate, this post will grow richer too.
Next post: Implementing a virtualization pattern with TanStack Virtual and
useInfiniteQueryto keep DOM node counts constant during infinite scroll with tens of thousands of items
References
- Infinite Scroll and Streaming Data with TanStack Query + React 19 | Makers' Den
- Infinite Queries | TanStack Query React Official Docs
- Prefetching & Router Integration | TanStack Query React Official Docs
- Advanced Server Rendering | TanStack Query React Official Docs
- use – React Official Docs
- React v19 Release Notes | react.dev
- The Next.js 15 Streaming Handbook | freeCodeCamp
- React TanStack Query Nextjs Suspense Streaming Example | TanStack Query Docs
- usePrefetchInfiniteQuery | TanStack Query React Official Docs