Implement Infinite Scroll with React Query and Next.js
Step-by-step guide to implementing performant infinite scroll in Next.js using React Query's useInfiniteQuery, Intersection Observer, and virtualized lists for large datasets.
Tags
Implement Infinite Scroll with React Query and Next.js
TL;DR
React Query's useInfiniteQuery hook combined with Intersection Observer gives you infinite scroll with automatic caching, background refetching, and seamless pagination -- no custom scroll event listeners needed.
Prerequisites
- ›Next.js 14+ with App Router
- ›Basic understanding of React Query (TanStack Query)
- ›A paginated API endpoint (we will build one)
- ›TypeScript familiarity
Step 1: Install Dependencies
npm install @tanstack/react-querySet up the React Query provider in your app layout:
// app/providers.tsx
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
export 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}
</QueryClientProvider>
);
}// app/layout.tsx
import { Providers } from "./providers";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}Step 2: Build a Cursor-Based Pagination API
Cursor-based pagination is more reliable than offset-based for infinite scroll because inserting or deleting records does not shift the page boundaries.
// app/api/posts/route.ts
import { NextRequest, NextResponse } from "next/server";
interface Post {
id: number;
title: string;
body: string;
author: string;
createdAt: string;
}
// Simulated database of 200 posts
const allPosts: Post[] = Array.from({ length: 200 }, (_, i) => ({
id: i + 1,
title: `Post #${i + 1}: ${["Building APIs", "React Patterns", "TypeScript Tips", "CSS Tricks"][i % 4]}`,
body: `This is the content for post ${i + 1}. It contains useful information about web development.`,
author: ["Alice", "Bob", "Charlie"][i % 3],
createdAt: new Date(2025, 0, 1 + i).toISOString(),
}));
export async function GET(req: NextRequest) {
const cursor = req.nextUrl.searchParams.get("cursor");
const limit = parseInt(req.nextUrl.searchParams.get("limit") || "20");
// Find the starting index based on cursor
let startIndex = 0;
if (cursor) {
const cursorIndex = allPosts.findIndex((p) => p.id === parseInt(cursor));
startIndex = cursorIndex + 1;
}
const items = allPosts.slice(startIndex, startIndex + limit);
const nextCursor = items.length === limit ? items[items.length - 1].id : null;
// Simulate network delay
await new Promise((r) => setTimeout(r, 300));
return NextResponse.json({
items,
nextCursor,
hasMore: nextCursor !== null,
});
}Why Cursor-Based over Offset-Based?
With offset pagination (?page=3&limit=20), if a new post is added while a user is scrolling, every subsequent page shifts by one item, causing duplicates or missed content. Cursor pagination (?cursor=60&limit=20) always starts after a specific record, making it stable regardless of insertions or deletions.
Step 3: Create the useInfiniteQuery Hook
Wrap useInfiniteQuery in a custom hook that encapsulates the fetch logic and type safety.
// hooks/useInfinitePosts.ts
"use client";
import { useInfiniteQuery } from "@tanstack/react-query";
interface Post {
id: number;
title: string;
body: string;
author: string;
createdAt: string;
}
interface PostsResponse {
items: Post[];
nextCursor: number | null;
hasMore: boolean;
}
async function fetchPosts({ cursor, limit = 20 }: { cursor?: number; limit?: number }): Promise<PostsResponse> {
const params = new URLSearchParams({ limit: String(limit) });
if (cursor) params.set("cursor", String(cursor));
const res = await fetch(`/api/posts?${params}`);
if (!res.ok) throw new Error("Failed to fetch posts");
return res.json();
}
export function useInfinitePosts(limit = 20) {
return useInfiniteQuery({
queryKey: ["posts", { limit }],
queryFn: ({ pageParam }) => fetchPosts({ cursor: pageParam, limit }),
initialPageParam: undefined as number | undefined,
getNextPageParam: (lastPage) =>
lastPage.hasMore ? lastPage.nextCursor ?? undefined : undefined,
});
}How useInfiniteQuery Works
- ›
queryFnreceives apageParamthat starts asundefinedfor the first page - ›
getNextPageParamextracts the cursor for the next page from the last fetched page - ›When it returns
undefined, React Query knows there are no more pages - ›Data is stored as
data.pages, an array where each element is one page of results
Step 4: Build the Intersection Observer Trigger
Instead of listening to scroll events, use the Intersection Observer API to detect when a sentinel element enters the viewport.
// hooks/useIntersectionObserver.ts
"use client";
import { useEffect, useRef } from "react";
interface UseIntersectionObserverOptions {
threshold?: number;
rootMargin?: string;
enabled?: boolean;
onIntersect: () => void;
}
export function useIntersectionObserver({
threshold = 0,
rootMargin = "200px",
enabled = true,
onIntersect,
}: UseIntersectionObserverOptions) {
const targetRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!enabled) return;
const target = targetRef.current;
if (!target) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting) {
onIntersect();
}
},
{ threshold, rootMargin }
);
observer.observe(target);
return () => observer.disconnect();
}, [threshold, rootMargin, enabled, onIntersect]);
return targetRef;
}The rootMargin: "200px" triggers the fetch 200 pixels before the sentinel actually enters the viewport, giving the network request a head start and making the scrolling feel seamless.
Step 5: Build the Infinite Scroll Component
Combine the hook and the observer into a complete infinite scroll feed.
// components/PostFeed.tsx
"use client";
import { useCallback } from "react";
import { useInfinitePosts } from "../hooks/useInfinitePosts";
import { useIntersectionObserver } from "../hooks/useIntersectionObserver";
export function PostFeed() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
isError,
error,
} = useInfinitePosts(20);
const handleIntersect = useCallback(() => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
const sentinelRef = useIntersectionObserver({
onIntersect: handleIntersect,
enabled: hasNextPage && !isFetchingNextPage,
});
if (isLoading) {
return <PostSkeleton count={5} />;
}
if (isError) {
return (
<div className="error">
<p>Failed to load posts: {error.message}</p>
<button onClick={() => window.location.reload()}>Retry</button>
</div>
);
}
const allPosts = data?.pages.flatMap((page) => page.items) ?? [];
return (
<div className="post-feed">
{allPosts.map((post) => (
<article key={post.id} className="post-card">
<h2>{post.title}</h2>
<p>{post.body}</p>
<div className="post-meta">
<span>{post.author}</span>
<span>{new Date(post.createdAt).toLocaleDateString()}</span>
</div>
</article>
))}
{/* Sentinel element - triggers fetch when visible */}
<div ref={sentinelRef} className="sentinel" />
{isFetchingNextPage && <PostSkeleton count={3} />}
{!hasNextPage && allPosts.length > 0 && (
<p className="end-message">You have reached the end.</p>
)}
</div>
);
}
function PostSkeleton({ count }: { count: number }) {
return (
<>
{Array.from({ length: count }, (_, i) => (
<div key={i} className="post-card skeleton">
<div className="skeleton-title" />
<div className="skeleton-body" />
<div className="skeleton-meta" />
</div>
))}
</>
);
}Step 6: Handle Scroll Restoration
When users navigate away and return, they expect to be at the same scroll position. React Query caches the data, but you need to restore the scroll position.
// hooks/useScrollRestoration.ts
"use client";
import { useEffect } from "react";
import { usePathname } from "next/navigation";
const scrollPositions = new Map<string, number>();
export function useScrollRestoration() {
const pathname = usePathname();
useEffect(() => {
// Restore position when component mounts
const savedPosition = scrollPositions.get(pathname);
if (savedPosition) {
window.scrollTo(0, savedPosition);
}
// Save position before navigating away
const savePosition = () => {
scrollPositions.set(pathname, window.scrollY);
};
window.addEventListener("beforeunload", savePosition);
return () => {
savePosition();
window.removeEventListener("beforeunload", savePosition);
};
}, [pathname]);
}Use it in the feed component:
export function PostFeed() {
useScrollRestoration();
// ... rest of the component
}Step 7: Add Virtualization for Large Lists
Once users scroll through hundreds of items, rendering all of them in the DOM becomes expensive. Add virtualization with @tanstack/react-virtual.
npm install @tanstack/react-virtual// components/VirtualizedPostFeed.tsx
"use client";
import { useRef, useCallback } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { useInfinitePosts } from "../hooks/useInfinitePosts";
export function VirtualizedPostFeed() {
const parentRef = useRef<HTMLDivElement>(null);
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfinitePosts(20);
const allPosts = data?.pages.flatMap((page) => page.items) ?? [];
const virtualizer = useVirtualizer({
count: hasNextPage ? allPosts.length + 1 : allPosts.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 150, // Estimated row height in pixels
overscan: 5,
});
const virtualItems = virtualizer.getVirtualItems();
// Fetch next page when the last virtual item is rendered
const lastItem = virtualItems[virtualItems.length - 1];
if (lastItem && lastItem.index >= allPosts.length - 1 && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
return (
<div ref={parentRef} style={{ height: "100vh", overflow: "auto" }}>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: "100%",
position: "relative",
}}
>
{virtualItems.map((virtualRow) => {
const post = allPosts[virtualRow.index];
if (!post) {
return (
<div
key="loader"
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
transform: `translateY(${virtualRow.start}px)`,
}}
>
Loading more...
</div>
);
}
return (
<article
key={post.id}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
transform: `translateY(${virtualRow.start}px)`,
}}
ref={virtualizer.measureElement}
data-index={virtualRow.index}
>
<h2>{post.title}</h2>
<p>{post.body}</p>
</article>
);
})}
</div>
</div>
);
}Putting It All Together
The complete infinite scroll system has four layers:
- ›API -- cursor-based pagination that returns items, the next cursor, and a
hasMoreflag - ›Data layer --
useInfiniteQuerymanages fetching, caching, and page merging - ›Trigger -- Intersection Observer detects when the user nears the bottom and calls
fetchNextPage - ›Rendering -- either a flat list for small datasets or a virtualized list for large ones
React Query handles the hard parts: caching pages across navigation, deduplicating simultaneous requests, and providing granular loading states for each page fetch.
Next Steps
- ›Add pull-to-refresh -- use
refetch()on a pull gesture to reload from the first page - ›Filter and search -- include query parameters in the
queryKeyso filtered lists get their own cache - ›Optimistic updates -- use
queryClient.setQueryDatato immediately insert new posts into the cached pages - ›Bidirectional scroll -- implement
fetchPreviousPagefor chat-style interfaces where new content loads upward - ›Server-side initial data -- use
prefetchInfiniteQueryin a Server Component to SSR the first page
FAQ
How does useInfiniteQuery differ from useQuery?
useInfiniteQuery manages paginated data as an array of pages rather than a single result. It provides fetchNextPage and hasNextPage helpers, automatically merges new pages into the existing cache, and tracks loading states per page rather than globally.
Why use Intersection Observer instead of scroll events?
Intersection Observer is more performant because it runs off the main thread and only fires when an element enters or exits the viewport. Scroll event listeners fire on every pixel of scroll, requiring throttling and manual calculations to avoid jank.
How do you handle large lists with infinite scroll?
For lists exceeding a few hundred items, combine infinite scroll with virtualization using libraries like @tanstack/react-virtual. Virtualization only renders visible items in the DOM, keeping memory usage constant regardless of how many items have been loaded.
Collaboration
Need help with a project?
Let's Build It
I help startups and established companies design, build, and scale world-class digital products. From deep technical architecture to pixel-perfect UI — let's bring your vision to life.
Related Articles
How to Add Observability to a Node.js App with OpenTelemetry
Learn how to instrument a Node.js app with OpenTelemetry for traces, metrics, and logs, and build a practical observability setup for production debugging.
How to Build a Backend-for-Frontend (BFF) with Next.js and Node.js
A practical guide to building a Backend-for-Frontend with Next.js and Node.js for API aggregation, auth handling, caching, and frontend-specific data shaping.
How I Structure CI/CD for Next.js, Docker, and GitHub Actions
A practical CI/CD blueprint for Next.js apps using Docker and GitHub Actions, including testing, image builds, deployment stages, cache strategy, and release safety.