React Query Infinite Scroll in 2026: useInfiniteQuery, Cursor Pagination, and Intersection Observer
Build infinite scroll with React Query useInfiniteQuery: cursor-based pagination, Intersection Observer trigger, bi-directional infinite scroll, and virtualized infinite lists.
React Query Infinite Scroll in 2026: useInfiniteQuery, Cursor Pagination, and Intersection Observer
Infinite scroll has replaced pagination on most content feeds. Done right โ cursor-based, not offset-based โ it's fast at any depth and handles concurrent inserts cleanly. Done wrong, it shows duplicate items when new content is inserted and gets progressively slower as users scroll deeper.
This post covers useInfiniteQuery with cursor pagination (no offset), an Intersection Observer trigger, bi-directional loading (load newer content above), and combining with react-virtual for lists with thousands of items.
API: Cursor-Based Pagination
// app/api/feed/route.ts
import { NextRequest, NextResponse } from "next/server";
import { getWorkspaceContext } from "@/lib/auth/workspace-context";
import { db } from "@/lib/db";
import { z } from "zod";
const QuerySchema = z.object({
cursor: z.string().optional(), // Last seen item ID
limit: z.coerce.number().min(1).max(50).default(20),
filter: z.enum(["all", "mine", "mentions"]).default("all"),
});
export async function GET(req: NextRequest) {
const ctx = await getWorkspaceContext();
if (!ctx) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const params = QuerySchema.safeParse(
Object.fromEntries(req.nextUrl.searchParams)
);
if (!params.success) return NextResponse.json({ error: "Invalid" }, { status: 400 });
const { cursor, limit, filter } = params.data;
const items = await db.feedItem.findMany({
where: {
workspaceId: ctx.workspaceId,
// Cursor: only items older than the cursor
...(cursor && {
id: { lt: cursor }, // ULID/UUID v7: lexicographic = chronological
}),
...(filter === "mine" && { actorId: ctx.userId }),
},
orderBy: { id: "desc" }, // Newest first
take: limit + 1, // Fetch one extra to check hasNextPage
include: {
actor: { select: { name: true, avatarUrl: true } },
},
});
const hasNextPage = items.length > limit;
const data = hasNextPage ? items.slice(0, limit) : items;
const nextCursor = hasNextPage ? data[data.length - 1].id : undefined;
return NextResponse.json({ items: data, nextCursor, hasNextPage });
}
useInfiniteQuery Setup
// hooks/useFeed.ts
import {
useInfiniteQuery,
useSuspenseInfiniteQuery,
type InfiniteData,
} from "@tanstack/react-query";
interface FeedItem {
id: string;
content: string;
actorId: string;
actor: { name: string; avatarUrl: string | null };
createdAt: string;
}
interface FeedPage {
items: FeedItem[];
nextCursor: string | undefined;
hasNextPage: boolean;
}
async function fetchFeedPage({
pageParam,
filter,
}: {
pageParam: string | undefined;
filter: string;
}): Promise<FeedPage> {
const url = new URL("/api/feed", window.location.origin);
url.searchParams.set("limit", "20");
url.searchParams.set("filter", filter);
if (pageParam) url.searchParams.set("cursor", pageParam);
const res = await fetch(url.toString());
if (!res.ok) throw new Error("Failed to load feed");
return res.json();
}
export function useFeed(filter: string = "all") {
return useInfiniteQuery({
queryKey: ["feed", filter],
queryFn: ({ pageParam }) => fetchFeedPage({ pageParam, filter }),
// First page has no cursor
initialPageParam: undefined as string | undefined,
// Extract cursor for next page from last page's response
getNextPageParam: (lastPage) => lastPage.nextCursor,
// Keep previous pages while fetching next (avoids list collapse)
placeholderData: (prev) => prev,
staleTime: 30 * 1000, // Consider fresh for 30 seconds
});
}
// Flattened list of all loaded items across pages
export function useFeedItems(filter?: string) {
const query = useFeed(filter);
const items = query.data?.pages.flatMap((page) => page.items) ?? [];
return { ...query, items };
}
๐ 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
Intersection Observer Trigger
// hooks/useIntersectionObserver.ts
import { useEffect, useRef, useState } from "react";
export function useIntersectionObserver(options?: IntersectionObserverInit) {
const ref = useRef<HTMLDivElement>(null);
const [isIntersecting, setIsIntersecting] = useState(false);
useEffect(() => {
const element = ref.current;
if (!element) return;
const observer = new IntersectionObserver(([entry]) => {
setIsIntersecting(entry.isIntersecting);
}, {
threshold: 0.1, // Trigger when 10% visible
rootMargin: "200px", // Start loading 200px before reaching bottom
...options,
});
observer.observe(element);
return () => observer.disconnect();
}, []);
return { ref, isIntersecting };
}
Complete Feed Component
// components/Feed/ActivityFeed.tsx
"use client";
import { useEffect } from "react";
import { useFeedItems } from "@/hooks/useFeed";
import { useIntersectionObserver } from "@/hooks/useIntersectionObserver";
import { FeedItem } from "./FeedItem";
import { Loader2 } from "lucide-react";
export function ActivityFeed({ filter = "all" }: { filter?: string }) {
const {
items,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
isError,
error,
} = useFeedItems(filter);
// Sentinel element at the bottom of the list
const { ref: sentinelRef, isIntersecting } = useIntersectionObserver({
rootMargin: "200px",
});
// Load next page when sentinel is visible
useEffect(() => {
if (isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [isIntersecting, hasNextPage, isFetchingNextPage, fetchNextPage]);
if (isLoading) {
return (
<div className="flex justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-gray-400" />
</div>
);
}
if (isError) {
return (
<div className="text-center py-8 text-red-500 text-sm">
Failed to load feed: {error instanceof Error ? error.message : "Unknown error"}
</div>
);
}
if (items.length === 0) {
return (
<div className="text-center py-12 text-gray-400 text-sm">
No activity yet.
</div>
);
}
return (
<div className="space-y-0 divide-y divide-gray-100">
{items.map((item) => (
<FeedItem key={item.id} item={item} />
))}
{/* Sentinel: triggers next page load when visible */}
<div ref={sentinelRef} className="py-4 flex justify-center">
{isFetchingNextPage && (
<Loader2 className="h-5 w-5 animate-spin text-gray-300" />
)}
{!hasNextPage && items.length > 0 && (
<p className="text-xs text-gray-300">You've reached the end</p>
)}
</div>
</div>
);
}
๐ 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
Bi-Directional: Loading Newer Items Above
For chat or live feeds where new items appear at the top:
// hooks/useBidirectionalFeed.ts
import { useInfiniteQuery } from "@tanstack/react-query";
import { useEffect, useRef } from "react";
export function useBidirectionalFeed(channelId: string) {
const scrollAnchorRef = useRef<HTMLDivElement>(null);
const query = useInfiniteQuery({
queryKey: ["messages", channelId],
queryFn: ({ pageParam }) =>
fetchMessages({ channelId, cursor: pageParam?.cursor, direction: pageParam?.direction ?? "older" }),
initialPageParam: undefined as { cursor?: string; direction: "older" | "newer" } | undefined,
getNextPageParam: (lastPage) =>
lastPage.hasOlderMessages ? { cursor: lastPage.oldestCursor, direction: "older" } : undefined,
getPreviousPageParam: (firstPage) =>
firstPage.hasNewerMessages ? { cursor: firstPage.newestCursor, direction: "newer" } : undefined,
});
// Maintain scroll position when prepending older messages
useEffect(() => {
if (!scrollAnchorRef.current) return;
scrollAnchorRef.current.scrollIntoView({ block: "start" });
}, [query.data?.pages.length]);
const messages = query.data?.pages.flatMap((p) => p.messages) ?? [];
return { ...query, messages, scrollAnchorRef };
}
Prefetching Next Page
// Prefetch next page before user reaches the sentinel (smoother UX)
import { useQueryClient } from "@tanstack/react-query";
function usePrefetchNextPage(filter: string) {
const queryClient = useQueryClient();
const { data } = useFeed(filter);
const prefetch = async () => {
const lastPage = data?.pages[data.pages.length - 1];
if (!lastPage?.nextCursor) return;
await queryClient.prefetchInfiniteQuery({
queryKey: ["feed", filter],
queryFn: ({ pageParam }) => fetchFeedPage({ pageParam, filter }),
initialPageParam: lastPage.nextCursor,
pages: 1, // Only prefetch one page ahead
});
};
return prefetch;
}
Combining with Virtualization
For lists with 10,000+ items that need to stay in DOM:
// components/Feed/VirtualizedFeed.tsx
"use client";
import { useEffect, useRef } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { useFeedItems } from "@/hooks/useFeed";
export function VirtualizedFeed() {
const { items, fetchNextPage, hasNextPage, isFetchingNextPage } = useFeedItems();
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: hasNextPage ? items.length + 1 : items.length, // +1 for loader row
getScrollElement: () => parentRef.current,
estimateSize: () => 80, // Estimated row height in px
overscan: 5, // Render 5 extra rows outside viewport
});
// Load more when last virtual row is rendered
useEffect(() => {
const [lastItem] = [...virtualizer.getVirtualItems()].reverse();
if (!lastItem) return;
if (
lastItem.index >= items.length - 1 &&
hasNextPage &&
!isFetchingNextPage
) {
fetchNextPage();
}
}, [
virtualizer.getVirtualItems(),
items.length,
hasNextPage,
isFetchingNextPage,
]);
return (
<div ref={parentRef} className="h-screen overflow-auto">
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
position: "relative",
}}
>
{virtualizer.getVirtualItems().map((virtualRow) => {
const isLoaderRow = virtualRow.index >= items.length;
const item = items[virtualRow.index];
return (
<div
key={virtualRow.key}
data-index={virtualRow.index}
ref={virtualizer.measureElement}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
transform: `translateY(${virtualRow.start}px)`,
}}
>
{isLoaderRow ? (
<div className="flex justify-center py-4">
{isFetchingNextPage ? (
<span className="text-sm text-gray-400">Loading more...</span>
) : (
<span className="text-sm text-gray-300">End of feed</span>
)}
</div>
) : (
<FeedItem item={item} />
)}
</div>
);
})}
</div>
</div>
);
}
Common Mistakes
// โ Offset pagination breaks with concurrent inserts
// Page 1: items 1-20
// User inserts item 0.5
// Page 2: OFFSET 20 โ item 20 is now item 21 โ item 20 duplicates!
fetch(`/api/feed?page=2&limit=20`);
// โ
Cursor pagination: always consistent
// cursor = last seen ID โ "give me items older than X"
// New inserts don't affect existing cursors
fetch(`/api/feed?cursor=${lastItemId}&limit=20`);
// โ Resetting query on filter change loses scroll position
const { data } = useInfiniteQuery({
queryKey: ["feed"], // Same key regardless of filter
// ...
});
// โ
Include filter in queryKey โ each filter has its own cache
const { data } = useInfiniteQuery({
queryKey: ["feed", filter],
// ...
});
// โ Fetching next page without checking hasNextPage
useEffect(() => {
if (isIntersecting) fetchNextPage(); // Keeps firing even at end
}, [isIntersecting]);
// โ
Guard against fetching past the end
useEffect(() => {
if (isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [isIntersecting, hasNextPage, isFetchingNextPage]);
Cost and Timeline
| Component | Timeline | Cost (USD) |
|---|---|---|
| Cursor pagination API endpoint | 0.5โ1 day | $400โ$800 |
| useInfiniteQuery + Intersection Observer | 0.5โ1 day | $400โ$800 |
| Bi-directional (chat-style) | 1โ2 days | $800โ$1,600 |
| Virtualized infinite list | 1โ2 days | $800โ$1,600 |
| Full infinite scroll system | 1โ2 weeks | $5,000โ$8,000 |
See Also
- React Query Server State โ React Query fundamentals
- React Virtualized Lists โ Virtualizing large lists
- SaaS Activity Feed โ Activity feed data model
- PostgreSQL UUID vs Serial โ Why ULID/UUID v7 works for cursor pagination
Working With Viprasol
We build infinite scroll feeds for SaaS products โ activity feeds, message lists, content libraries, and search results. Our team has shipped infinite scroll implementations handling tens of thousands of items with consistent performance.
What we deliver:
- Cursor-based pagination API (no offset, handles concurrent inserts)
useInfiniteQuerywith Intersection Observer sentinel- Bi-directional loading for chat/real-time feeds
- Virtualized infinite list with
@tanstack/react-virtual - Prefetch strategy for seamless user experience
Explore our web development services or contact us to build your infinite scroll feature.
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.