Production Infinite Scroll with Claude Code, React, and Cursor APIs
Build infinite scroll with Claude Code, React, cursor APIs, accessibility, SEO, and real error handling.
Infinite scroll is the pattern where a page loads the next set of items as the reader approaches the end of the current list. It feels simple in a social feed, article index, product grid, or admin activity log, but the production version is not just “fetch when the last card is visible.”
The hard parts are duplicate requests, stale responses, browser back behavior, accessible status updates, SEO-friendly URLs, and an API that keeps a stable order while new records are created. If you ask Claude Code only to “add infinite scroll,” it may produce a nice demo and still miss the details that matter after launch.
This guide turns the implementation into a promptable, reviewable workflow. It includes the Claude Code prompt, a reusable React hook, a feed component, a Next.js cursor API route, more than three practical use cases, concrete pitfalls, internal links, official references, and a hands-on verification note. For very large DOM lists, pair this with virtual scrolling. For cases where the user needs explicit page numbers, compare it with pagination implementation.
Design First
Start by naming the pieces. Intersection Observer is a browser API that reports when a target element intersects with the viewport or another root element. In plain language, it lets the browser tell you when the “load more” marker is near the screen. MDN’s Intersection Observer API documentation is the right baseline for the behavior and terminology.
The small element at the end of the list is often called a sentinel. When the sentinel becomes visible, the app loads another page. This is lighter and cleaner than attaching a scroll handler that runs on every pixel of movement.
The second design decision is pagination style. Offset pagination asks for “items 41 through 60.” That works for static lists, but live feeds can shift while a user is reading. Cursor pagination asks for “items after this id.” For article feeds, notifications, audit logs, and search results that change over time, cursor pagination is usually the safer default.
Give Claude Code these constraints up front.
Implement an infinite article feed in React and Next.js.
Use Intersection Observer and a cursor-based API.
Include duplicate request prevention, AbortController cleanup, visible error handling,
a manual Load more button, aria-live updates, role="feed", and SEO-safe links.
Do not remove existing frontmatter, heroImage, internal links, or localized routes.
Anthropic’s Claude Code common workflows emphasize giving clear tasks, examples, and constraints. Infinite scroll benefits from that discipline because the feature crosses UI, API, accessibility, and product behavior.
Practical Use Cases
Use case one is an article archive. A site with many Claude Code tutorials can keep the initial page fast while letting motivated readers keep browsing. The risk is return navigation: if a reader opens an article and comes back to the list, losing the scroll position is frustrating.
Use case two is ecommerce or SaaS search. Infinite scroll can make browsing templates, integrations, or products feel fluid. It only works well if filters, sorting, and query text remain shareable in the URL. Otherwise a user cannot send the same result set to a teammate.
Use case three is an admin audit log or notification center. The newest records are usually most important, and operators want to scan quickly. Here the cursor, timestamp, and read state must be separate concepts. Do not use “last seen” as both a database cursor and a product status.
Use case four is chat, comments, or activity streams. These often need reverse infinite scroll: loading older messages above the current viewport. Tell Claude Code the loading direction explicitly, because a downward feed and an upward history view have different scroll restoration problems.
Use case five is a learning dashboard. A reader may move through lessons, examples, and checklists over several sessions. Infinite scroll can reduce navigation friction, but the app still needs stable lesson URLs, progress markers, and a reachable CTA such as Claude Code training.
React Hook
The hook below is intentionally small but production-minded. It assumes a cursor API, cancels old work on unmount, prevents duplicate fetches with a ref, and triggers loading before the sentinel fully reaches the viewport.
import { useCallback, useEffect, useRef, useState } from "react";
export type CursorPage<T> = {
items: T[];
nextCursor: string | null;
};
type FetchPage<T> = (args: {
cursor: string | null;
signal: AbortSignal;
}) => Promise<CursorPage<T>>;
type InfiniteStatus = "idle" | "loading" | "error" | "done";
type UseInfiniteCursorOptions<T> = {
fetchPage: FetchPage<T>;
mergeItems?: (previous: T[], next: T[]) => T[];
initialCursor?: string | null;
};
export function useInfiniteCursor<T>({
fetchPage,
mergeItems,
initialCursor = null,
}: UseInfiniteCursorOptions<T>) {
const [items, setItems] = useState<T[]>([]);
const [cursor, setCursor] = useState<string | null>(initialCursor);
const [status, setStatus] = useState<InfiniteStatus>("idle");
const [error, setError] = useState<Error | null>(null);
const abortRef = useRef<AbortController | null>(null);
const observerRef = useRef<IntersectionObserver | null>(null);
const loadingRef = useRef(false);
const hasMore = cursor !== null || items.length === 0;
const loadMore = useCallback(async () => {
if (loadingRef.current || !hasMore) return;
loadingRef.current = true;
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setStatus("loading");
setError(null);
try {
const page = await fetchPage({ cursor, signal: controller.signal });
setItems((previous) =>
mergeItems ? mergeItems(previous, page.items) : [...previous, ...page.items],
);
setCursor(page.nextCursor);
setStatus(page.nextCursor ? "idle" : "done");
} catch (unknownError) {
if (unknownError instanceof DOMException && unknownError.name === "AbortError") {
return;
}
setError(unknownError instanceof Error ? unknownError : new Error("Load failed"));
setStatus("error");
} finally {
loadingRef.current = false;
}
}, [cursor, fetchPage, hasMore, mergeItems]);
const sentinelRef = useCallback(
(node: HTMLElement | null) => {
observerRef.current?.disconnect();
if (!node || !hasMore) return;
observerRef.current = new IntersectionObserver(
([entry]) => {
if (entry?.isIntersecting) void loadMore();
},
{ rootMargin: "600px 0px", threshold: 0 },
);
observerRef.current.observe(node);
},
[hasMore, loadMore],
);
useEffect(() => {
void loadMore();
return () => {
abortRef.current?.abort();
observerRef.current?.disconnect();
};
}, [loadMore]);
return {
items,
status,
error,
hasMore,
loadMore,
sentinelRef,
};
}
For effect cleanup and external subscriptions, use the official React useEffect reference as the baseline. When you ask Claude Code to review this hook, explicitly mention cleanup for both IntersectionObserver and AbortController.
Feed Component
The component keeps automatic loading and manual recovery on the same path. If Intersection Observer fails, a corporate browser blocks behavior, or the user prefers keyboard control, the button still loads the next page.
import { useCallback } from "react";
import { useInfiniteCursor, type CursorPage } from "./useInfiniteCursor";
type Article = {
id: string;
title: string;
summary: string;
href: string;
publishedAt: string;
};
function mergeUniqueById(previous: Article[], next: Article[]) {
const seen = new Set(previous.map((item) => item.id));
return [...previous, ...next.filter((item) => !seen.has(item.id))];
}
async function fetchArticlePage({
cursor,
signal,
}: {
cursor: string | null;
signal: AbortSignal;
}): Promise<CursorPage<Article>> {
const params = new URLSearchParams({ limit: "20" });
if (cursor) params.set("cursor", cursor);
const response = await fetch(`/api/articles?${params}`, { signal });
if (!response.ok) throw new Error(`Failed to load articles: ${response.status}`);
return response.json();
}
export function ArticleFeed() {
const fetchPage = useCallback(fetchArticlePage, []);
const { items, status, error, hasMore, loadMore, sentinelRef } = useInfiniteCursor({
fetchPage,
mergeItems: mergeUniqueById,
});
return (
<section aria-labelledby="article-feed-title">
<h2 id="article-feed-title">Latest articles</h2>
<div role="feed" aria-busy={status === "loading"}>
{items.map((article, index) => (
<article
key={article.id}
role="article"
aria-posinset={index + 1}
aria-setsize={hasMore ? -1 : items.length}
>
<a href={article.href}>
<h3>{article.title}</h3>
</a>
<p>{article.summary}</p>
<time dateTime={article.publishedAt}>
{new Intl.DateTimeFormat("en-US").format(new Date(article.publishedAt))}
</time>
</article>
))}
</div>
{error && (
<p role="alert">
Loading failed. Check your connection and try again.
</p>
)}
<div ref={sentinelRef} aria-hidden="true" />
<p aria-live="polite">
{status === "loading" && "Loading more articles."}
{status === "done" && "All articles are visible."}
</p>
{hasMore && (
<button type="button" onClick={() => void loadMore()} disabled={status === "loading"}>
Load more
</button>
)}
</section>
);
}
If you use role="feed", check the WAI-ARIA feed pattern. You may not need every ARIA attribute for a simple marketing list, but the pattern forces a useful question: can a reader understand position, loading state, and failure state without seeing the animation?
Next.js Cursor Route
The frontend cannot fix an unstable API. The route below fetches limit + 1 rows, returns only limit, and uses the extra row to decide whether a next cursor exists.
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const limit = Math.min(Math.max(Number(searchParams.get("limit") ?? "20"), 1), 50);
const cursor = searchParams.get("cursor");
const rows = await prisma.article.findMany({
take: limit + 1,
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
orderBy: [{ publishedAt: "desc" }, { id: "desc" }],
select: {
id: true,
title: true,
summary: true,
href: true,
publishedAt: true,
},
});
const items = rows.slice(0, limit);
const nextCursor = rows.length > limit ? items.at(-1)?.id ?? null : null;
return NextResponse.json({ items, nextCursor });
}
In production, confirm the database index matches the sort. UI smoothness depends on query stability. If the route slows down after the article table grows, the frontend will look broken even when the observer logic is correct. Use the same measurement mindset you would apply in performance optimization.
Pitfalls And Failure Modes
The first failure is repeated observer firing. If the sentinel remains visible while rendering is slow, the browser may trigger another load before state catches up. A synchronous ref lock is more reliable than relying only on React state.
The second failure is offset pagination on a changing feed. A new article inserted at the top can make page two overlap with page one. For live lists, prefer cursor pagination and deduplicate by stable id on the client.
The third issue is an unreachable footer or CTA. Infinite scroll can keep pushing the footer away, which hurts trust pages, contact links, and offers such as Claude Code training. Stop automatic loading after a sensible number of pages or place a manual boundary before the footer.
The fourth problem is SEO. Crawlers and social previews cannot depend on a user’s scroll state. Keep normal links, category URLs, sitemap entries, and page-level metadata. Infinite scroll should enhance navigation, not replace your content architecture.
The fifth risk is browser back behavior. If a user opens an item and returns to the feed, starting at the top again is a serious UX regression. Test scroll restoration, cache behavior, and URL state for filters and cursors.
Claude Code Review Prompt
After implementation, ask Claude Code for a failure-mode review, not a vague style pass.
Review this infinite scroll implementation for production risks.
Check duplicate fetches, stale responses, IntersectionObserver cleanup,
AbortError handling, cursor pagination, accessibility, SEO, browser back behavior,
database indexing, and manual recovery after failure.
Return file-level findings and concrete fixes.
For the tool itself, Anthropic’s Claude Code overview is the official starting point. The more agentic the workflow becomes, the more important your constraints and review checklist become.
Summary And CTA
Infinite scroll feels like a small UI improvement, but the real feature spans the browser, API, database, accessibility, SEO, and product funnel. With Claude Code, ask for the whole workflow: Intersection Observer, cursor API, manual fallback, cleanup, scroll restoration, and verification.
If your team wants to turn this into a repeatable delivery habit, the natural next step is Claude Code training. The goal is not only to generate a hook once. It is to teach the team how to specify, review, test, and ship this class of feature safely.
What I Verified
For this refresh, I checked MDN, React, WAI-ARIA, and Anthropic documentation, then replaced the corrupted article with a production-focused implementation. The code is structured as valid TypeScript and TSX, with duplicate request protection, AbortController, a cursor API, manual recovery, and aria-live status updates. In a real project, I would finish with npm run build, API load checks, mobile browser testing, and browser back restoration checks.
Free PDF: Claude Code Cheatsheet
Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.
We handle your data with care and never send spam.
Level up your Claude Code workflow
Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.
About the Author
Masa
Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.
Related Posts
Claude Code Permission Safety Ladder: Expand Access Without Losing Control
A beginner-friendly ladder for moving Claude Code from read-only to limited edits, proof commands, and deploy checks.
Claude Code Small PR Proof Pack: Make Tiny Changes Reviewable
A practical proof pack for Claude Code PRs: diff, checks, public URL, CTA path, and rollback note.
Claude Code Review Gate Before Commit: Diff, Tests, Public URL, and CTA Checks
A commit-time review gate for Claude Code work: diff scope, build, public URL, revenue CTA links, missing tests, and unrelated files.
Related Products
50 Battle-Tested Claude Code Prompt Templates
Copy, paste, ship. 50 production-ready prompts.
Use proven prompts for code review, refactoring, testing, documentation, debugging, architecture, and incident response.
The Complete Claude Code Setup & Configuration Guide
From install to team-ready workflow.
A practical guide to installation, CLAUDE.md, hooks, MCP servers, permissions, IDE setup, and CI/CD workflows.