Infinite loading replaces pagination with auto-fetching as users scroll. Here is how to build it well.
useInfiniteScroll Hook
// hooks/useInfiniteScroll.ts
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
interface UseInfiniteScrollOptions<T> {
fetchFn: (cursor: string | null) => Promise<{ items: T[]; nextCursor: string | null }>;
initialCursor?: string | null;
}
export function useInfiniteScroll<T>({
fetchFn,
initialCursor = null,
}: UseInfiniteScrollOptions<T>) {
const [items, setItems] = useState<T[]>([]);
const [cursor, setCursor] = useState<string | null>(initialCursor);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [error, setError] = useState<string | null>(null);
const observerRef = useRef<IntersectionObserver | null>(null);
const loadingRef = useRef(false);
const loadMore = useCallback(async () => {
if (loadingRef.current || !hasMore) return;
loadingRef.current = true;
setLoading(true);
setError(null);
try {
const result = await fetchFn(cursor);
setItems((prev) => [...prev, ...result.items]);
setCursor(result.nextCursor);
setHasMore(result.nextCursor !== null);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load");
} finally {
setLoading(false);
loadingRef.current = false;
}
}, [cursor, hasMore, fetchFn]);
const sentinelRef = useCallback(
(node: HTMLElement | null) => {
if (observerRef.current) observerRef.current.disconnect();
observerRef.current = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
loadMore();
}
},
{ rootMargin: "200px" }
);
if (node) observerRef.current.observe(node);
},
[loadMore]
);
// Initial load
useEffect(() => {
if (items.length === 0) loadMore();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const reset = useCallback(() => {
setItems([]);
setCursor(initialCursor);
setHasMore(true);
setError(null);
}, [initialCursor]);
return { items, loading, hasMore, error, sentinelRef, reset };
}
API Route with Cursor Pagination
// app/api/posts/route.ts
import { NextRequest, NextResponse } from "next/server";
interface Post {
id: string;
title: string;
excerpt: string;
date: string;
author: string;
}
export async function GET(request: NextRequest) {
const cursor = request.nextUrl.searchParams.get("cursor");
const limit = parseInt(request.nextUrl.searchParams.get("limit") ?? "20", 10);
// In production: use database cursor pagination
// const posts = await db
// .select()
// .from(postsTable)
// .where(cursor ? gt(postsTable.id, cursor) : undefined)
// .orderBy(postsTable.id)
// .limit(limit + 1);
const allPosts = generateMockPosts();
const startIndex = cursor ? allPosts.findIndex((p) => p.id === cursor) + 1 : 0;
const slice = allPosts.slice(startIndex, startIndex + limit + 1);
const hasMore = slice.length > limit;
const items = hasMore ? slice.slice(0, limit) : slice;
const nextCursor = hasMore ? items[items.length - 1].id : null;
return NextResponse.json({ items, nextCursor });
}
function generateMockPosts(): Post[] {
return Array.from({ length: 100 }, (_, i) => ({
id: `post-${i + 1}`,
title: `Post number ${i + 1}`,
excerpt: `This is the excerpt for post ${i + 1}.`,
date: new Date(2026, 0, i + 1).toISOString(),
author: "Ryel Banfield",
}));
}
Infinite Post List Component
// components/InfinitePostList.tsx
"use client";
import { useInfiniteScroll } from "@/hooks/useInfiniteScroll";
interface Post {
id: string;
title: string;
excerpt: string;
date: string;
author: string;
}
async function fetchPosts(cursor: string | null) {
const params = new URLSearchParams({ limit: "20" });
if (cursor) params.set("cursor", cursor);
const res = await fetch(`/api/posts?${params}`);
if (!res.ok) throw new Error("Failed to load posts");
return res.json() as Promise<{ items: Post[]; nextCursor: string | null }>;
}
export function InfinitePostList() {
const { items, loading, hasMore, error, sentinelRef } =
useInfiniteScroll<Post>({ fetchFn: fetchPosts });
return (
<div className="max-w-2xl mx-auto space-y-4">
{items.map((post) => (
<article key={post.id} className="border rounded-lg p-4">
<h2 className="text-lg font-semibold">{post.title}</h2>
<p className="text-sm text-muted-foreground mt-1">{post.excerpt}</p>
<div className="flex items-center gap-2 mt-2 text-xs text-muted-foreground">
<span>{post.author}</span>
<span>·</span>
<time>{new Date(post.date).toLocaleDateString()}</time>
</div>
</article>
))}
{/* Loading skeletons */}
{loading && (
<div className="space-y-4">
{Array.from({ length: 3 }, (_, i) => (
<div key={i} className="border rounded-lg p-4 animate-pulse">
<div className="h-5 bg-muted rounded w-3/4" />
<div className="h-4 bg-muted rounded w-full mt-2" />
<div className="h-3 bg-muted rounded w-1/3 mt-2" />
</div>
))}
</div>
)}
{/* Error state */}
{error && (
<div className="text-center py-4">
<p className="text-red-600 text-sm">{error}</p>
<button
onClick={() => window.location.reload()}
className="text-sm text-primary underline mt-1"
>
Try again
</button>
</div>
)}
{/* Sentinel element */}
{hasMore && !error && (
<div ref={sentinelRef} className="h-1" aria-hidden="true" />
)}
{/* End of list */}
{!hasMore && items.length > 0 && (
<p className="text-center text-sm text-muted-foreground py-4">
You have reached the end.
</p>
)}
</div>
);
}
With Filters
"use client";
import { useCallback, useState } from "react";
import { useInfiniteScroll } from "@/hooks/useInfiniteScroll";
export function FilteredInfiniteList() {
const [category, setCategory] = useState<string>("all");
const fetchFn = useCallback(
async (cursor: string | null) => {
const params = new URLSearchParams({ limit: "20" });
if (cursor) params.set("cursor", cursor);
if (category !== "all") params.set("category", category);
const res = await fetch(`/api/posts?${params}`);
return res.json();
},
[category]
);
const { items, loading, hasMore, sentinelRef, reset } = useInfiniteScroll({
fetchFn,
});
function handleCategoryChange(newCategory: string) {
setCategory(newCategory);
reset(); // Clear items and reload with new filter
}
return (
<div>
<div className="flex gap-2 mb-4">
{["all", "tech", "design", "business"].map((cat) => (
<button
key={cat}
onClick={() => handleCategoryChange(cat)}
className={`px-3 py-1 rounded text-sm ${
category === cat ? "bg-primary text-primary-foreground" : "bg-muted"
}`}
>
{cat}
</button>
))}
</div>
{/* ... render items, sentinel, etc. */}
</div>
);
}
Need Performant Data-Heavy Interfaces?
We build fast, scalable frontends that handle large datasets smoothly. Contact us to discuss your project.