React Infinite Scroll: Intersection Observer, React Query Infinite, and Cursor Pagination
Build performant infinite scroll in React with Intersection Observer and React Query useInfiniteQuery. Covers cursor pagination, loading states, error recovery, virtual scrolling for large lists, and accessibility considerations.
Infinite scroll done right is seamless β users load more content just by scrolling, with no jarring page reloads or confusing pagination controls. Done wrong, it's a performance nightmare: too many DOM nodes, broken back navigation, inaccessible to keyboard users, and duplicated items when new records are inserted mid-scroll.
This guide covers the correct patterns: Intersection Observer for the scroll trigger, cursor-based pagination to prevent duplicates, React Query's useInfiniteQuery for data management, and react-virtual for lists so large they need virtualization.
Cursor vs Offset Pagination
// β Offset pagination: page=2&limit=20 β breaks with concurrent inserts
// If a new record is inserted while you're on page 1:
// Page 1: items 1-20
// New item inserted at position 1
// Page 2: items 21-40 (now page 2 starts at the old item 20 β duplicate!)
// β
Cursor pagination: after=<id> β stable even with concurrent inserts
// Page 1: first 20 items β returns cursor = last item's ID
// Page 2: items after cursor ID (never duplicates regardless of inserts)
API: Cursor-Paginated Endpoint
// app/api/feed/route.ts
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { auth } from "@/auth";
import { z } from "zod";
const FeedQuerySchema = z.object({
cursor: z.string().uuid().optional(),
limit: z.coerce.number().int().min(1).max(50).default(20),
});
export async function GET(req: NextRequest) {
const session = await auth();
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const params = Object.fromEntries(req.nextUrl.searchParams);
const parsed = FeedQuerySchema.safeParse(params);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues[0].message }, { status: 400 });
}
const { cursor, limit } = parsed.data;
const items = await prisma.feedItem.findMany({
where: { workspaceId: session.user.workspaceId },
take: limit + 1, // Fetch one extra to determine if there's a next page
skip: cursor ? 1 : 0,
cursor: cursor ? { id: cursor } : undefined,
orderBy: { createdAt: "desc" },
select: {
id: true,
type: true,
title: true,
body: true,
actor: { select: { id: true, name: true, avatarUrl: true } },
createdAt: true,
},
});
const hasNextPage = items.length > limit;
const data = hasNextPage ? items.slice(0, limit) : items;
const nextCursor = hasNextPage ? data[data.length - 1].id : null;
return NextResponse.json({
items: data,
nextCursor,
hasNextPage,
});
}
π Looking for a Dev Team That Actually Delivers?
Most agencies sell you a project manager and assign juniors. Viprasol is different β senior engineers only, direct Slack access, and a 5.0β Upwork record across 100+ projects.
- React, Next.js, Node.js, TypeScript β production-grade stack
- Fixed-price contracts β no surprise invoices
- Full source code ownership from day one
- 90-day post-launch support included
useInfiniteQuery Hook
// hooks/use-feed.ts
import { useInfiniteQuery } from "@tanstack/react-query";
interface FeedItem {
id: string;
type: string;
title: string;
body: string;
actor: { id: string; name: string; avatarUrl: string | null };
createdAt: string;
}
interface FeedPage {
items: FeedItem[];
nextCursor: string | null;
hasNextPage: boolean;
}
async function fetchFeedPage(cursor?: string): Promise<FeedPage> {
const url = new URL("/api/feed", window.location.origin);
if (cursor) url.searchParams.set("cursor", cursor);
url.searchParams.set("limit", "20");
const res = await fetch(url.toString());
if (!res.ok) throw new Error("Failed to fetch feed");
return res.json();
}
export function useFeed() {
return useInfiniteQuery({
queryKey: ["feed"],
queryFn: ({ pageParam }) => fetchFeedPage(pageParam as string | undefined),
// TanStack Query v5: getNextPageParam extracts cursor from last page
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
initialPageParam: undefined as string | undefined,
// Keep first page fresh (30s), cache subsequent pages for 5min
staleTime: 30_000,
});
}
Intersection Observer Hook
// hooks/use-intersection-observer.ts
import { useEffect, useRef, useState } from "react";
interface UseIntersectionObserverOptions {
threshold?: number;
rootMargin?: string;
enabled?: boolean;
}
export function useIntersectionObserver(
options: UseIntersectionObserverOptions = {}
) {
const { threshold = 0, rootMargin = "0px", enabled = true } = options;
const [isIntersecting, setIsIntersecting] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!enabled || !ref.current) return;
const observer = new IntersectionObserver(
([entry]) => setIsIntersecting(entry.isIntersecting),
{ threshold, rootMargin }
);
observer.observe(ref.current);
return () => observer.disconnect();
}, [enabled, threshold, rootMargin]);
return { ref, isIntersecting };
}
π Senior Engineers. No Junior Handoffs. Ever.
You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.
- MVPs in 4β8 weeks, full platforms in 3β5 months
- Lighthouse 90+ performance scores standard
- Works across US, UK, AU timezones
- Free 30-min architecture review, no commitment
Feed Component
// components/feed/activity-feed.tsx
"use client";
import { useEffect } from "react";
import { useFeed } from "@/hooks/use-feed";
import { useIntersectionObserver } from "@/hooks/use-intersection-observer";
import { FeedItem } from "./feed-item";
import { FeedItemSkeleton } from "./feed-item-skeleton";
import { AlertCircle, RotateCcw } from "lucide-react";
export function ActivityFeed() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
isError,
error,
refetch,
} = useFeed();
// Sentinel element at the bottom of the list
const { ref: sentinelRef, isIntersecting } = useIntersectionObserver({
rootMargin: "200px", // Start loading 200px before sentinel is visible
enabled: hasNextPage && !isFetchingNextPage,
});
// Trigger next page when sentinel becomes visible
useEffect(() => {
if (isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [isIntersecting, hasNextPage, isFetchingNextPage, fetchNextPage]);
// Flatten pages into a single list
const items = data?.pages.flatMap((page) => page.items) ?? [];
if (isLoading) {
return (
<div className="space-y-4">
{Array.from({ length: 8 }).map((_, i) => (
<FeedItemSkeleton key={i} />
))}
</div>
);
}
if (isError) {
return (
<div className="flex flex-col items-center gap-3 py-12 text-center">
<AlertCircle className="w-8 h-8 text-red-400" />
<p className="text-sm font-medium text-gray-900">Failed to load feed</p>
<p className="text-xs text-gray-500">{(error as Error).message}</p>
<button
onClick={() => refetch()}
className="flex items-center gap-2 text-sm text-blue-600 hover:underline mt-1"
>
<RotateCcw className="w-3.5 h-3.5" />
Try again
</button>
</div>
);
}
if (!items.length) {
return (
<div className="py-12 text-center">
<p className="text-sm text-gray-500">No activity yet.</p>
</div>
);
}
return (
<div className="space-y-1" role="feed" aria-label="Activity feed" aria-busy={isFetchingNextPage}>
{items.map((item, index) => (
<FeedItem
key={item.id}
item={item}
// aria-posinset and aria-setsize inform screen readers of position
aria-posinset={index + 1}
aria-setsize={hasNextPage ? -1 : items.length} // -1 = unknown total
/>
))}
{/* Sentinel: triggers next page load when scrolled into view */}
<div ref={sentinelRef} className="h-1" aria-hidden="true" />
{/* Loading skeleton for next page */}
{isFetchingNextPage && (
<div className="space-y-4 pt-2">
{Array.from({ length: 3 }).map((_, i) => (
<FeedItemSkeleton key={i} />
))}
</div>
)}
{/* End of list */}
{!hasNextPage && items.length > 0 && (
<p className="text-center text-xs text-gray-400 py-6">
You've reached the end β {items.length} items total
</p>
)}
</div>
);
}
Feed Item Component
// components/feed/feed-item.tsx
import { formatDistanceToNow } from "date-fns";
import Image from "next/image";
import { type HTMLAttributes } from "react";
interface FeedItemData {
id: string;
type: string;
title: string;
body: string;
actor: { id: string; name: string; avatarUrl: string | null };
createdAt: string;
}
interface FeedItemProps extends HTMLAttributes<HTMLDivElement> {
item: FeedItemData;
}
export function FeedItem({ item, ...props }: FeedItemProps) {
return (
<div
className="flex items-start gap-3 p-4 hover:bg-gray-50 rounded-xl transition-colors"
role="article"
aria-label={item.title}
{...props}
>
{/* Actor avatar */}
<div className="w-8 h-8 rounded-full bg-blue-100 flex items-center justify-center text-sm font-semibold text-blue-700 flex-shrink-0">
{item.actor.avatarUrl ? (
<Image src={item.actor.avatarUrl} alt="" width={32} height={32} className="rounded-full" />
) : (
item.actor.name[0].toUpperCase()
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm text-gray-900">
<span className="font-medium">{item.actor.name}</span>{" "}
{item.title}
</p>
{item.body && (
<p className="text-xs text-gray-500 mt-0.5 truncate">{item.body}</p>
)}
<time
dateTime={item.createdAt}
className="text-xs text-gray-400 mt-1 block"
>
{formatDistanceToNow(new Date(item.createdAt), { addSuffix: true })}
</time>
</div>
</div>
);
}
Virtualization for Very Large Lists
// For lists with thousands of items: use react-virtual
// npm install @tanstack/react-virtual
"use client";
import { useVirtualizer } from "@tanstack/react-virtual";
import { useRef, useEffect } from "react";
import { useFeed } from "@/hooks/use-feed";
export function VirtualizedFeed() {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useFeed();
const items = data?.pages.flatMap((p) => p.items) ?? [];
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: hasNextPage ? items.length + 1 : items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 72, // Estimated row height in px
overscan: 5, // Render 5 extra items outside viewport
});
// Fetch next page when last virtual item is visible
useEffect(() => {
const [lastItem] = [...virtualizer.getVirtualItems()].reverse();
if (!lastItem) return;
if (
lastItem.index >= items.length - 1 &&
hasNextPage &&
!isFetchingNextPage
) {
fetchNextPage();
}
}, [virtualizer.getVirtualItems(), items.length, hasNextPage, isFetchingNextPage, fetchNextPage]);
return (
<div ref={parentRef} className="h-[600px] overflow-auto">
<div style={{ height: `${virtualizer.getTotalSize()}px`, position: "relative" }}>
{virtualizer.getVirtualItems().map((virtualItem) => {
const item = items[virtualItem.index];
return (
<div
key={virtualItem.key}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
transform: `translateY(${virtualItem.start}px)`,
}}
>
{item ? (
<FeedItem item={item} />
) : (
<FeedItemSkeleton /> // Loading item at end
)}
</div>
);
})}
</div>
</div>
);
}
Keyboard Accessibility
// "Load more" button as alternative for keyboard users who can't scroll
function LoadMoreButton({ onClick, loading }: { onClick: () => void; loading: boolean }) {
return (
<button
onClick={onClick}
disabled={loading}
className="w-full py-3 text-sm font-medium text-blue-600 hover:bg-blue-50 rounded-xl border border-blue-200 disabled:opacity-50"
aria-label="Load more items"
>
{loading ? "Loadingβ¦" : "Load more"}
</button>
);
}
// Use both: Intersection Observer for mouse users + Load More button for keyboard users
Cost and Timeline Estimates
| Scope | Team | Timeline | Cost Range |
|---|---|---|---|
| Basic infinite scroll (Intersection Observer + useInfiniteQuery) | 1 dev | 1β2 days | $300β600 |
| Full feed with loading states, error recovery, accessibility | 1 dev | 3β5 days | $800β1,500 |
| + react-virtual for >1,000 items | 1 dev | 1β2 days | $300β600 |
See Also
- React Query Advanced Patterns
- React Query Infinite Scroll Patterns
- React Virtualized Lists
- SaaS Activity Feed Implementation
- PostgreSQL Schema Design for SaaS
Working With Viprasol
Infinite scroll feels simple but has real gotchas: offset pagination breaks with concurrent inserts, too many DOM nodes tank scroll performance, and forgetting keyboard accessibility excludes a meaningful portion of your users. Our team builds infinite scroll with cursor-based pagination, Intersection Observer, and react-virtual for large lists β tested on real production data volumes.
What we deliver:
- Cursor-paginated API endpoint (Prisma findMany with cursor + take+1 pattern)
useInfiniteQuerywithgetNextPageParamextracting cursor from last pageuseIntersectionObserverhook with rootMargin for early load trigger- ActivityFeed with skeleton loading, error state, end-of-list message
useVirtualizerintegration for lists > 1,000 items
Talk to our team about your feed and list architecture β
Or explore our web development services.
About the Author
Viprasol Tech Team
Custom Software Development Specialists
The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.
Need a Modern Web Application?
From landing pages to complex SaaS platforms β we build it all with Next.js and React.
Free consultation β’ No commitment β’ Response within 24 hours
Need a custom web application built?
We build React and Next.js web applications with Lighthouse β₯90 scores, mobile-first design, and full source code ownership. Senior engineers only β from architecture through deployment.