Infinite scroll loads new content automatically as users reach the bottom of the page. It works well for feeds, product listings, and blog archives. Here is how to build it properly.
Step 1: Create the Server Action
// app/actions.ts
"use server";
import { db } from "@/db";
import { posts } from "@/db/schema";
import { desc, lt } from "drizzle-orm";
const PAGE_SIZE = 12;
export async function loadMorePosts(cursor?: string) {
const query = db
.select()
.from(posts)
.orderBy(desc(posts.createdAt))
.limit(PAGE_SIZE + 1); // Fetch one extra to check if there are more
if (cursor) {
query.where(lt(posts.createdAt, new Date(cursor)));
}
const results = await query;
const hasMore = results.length > PAGE_SIZE;
const items = hasMore ? results.slice(0, PAGE_SIZE) : results;
const nextCursor = hasMore
? items[items.length - 1].createdAt.toISOString()
: undefined;
return { items, nextCursor };
}
Step 2: Build the Infinite Scroll Hook
// hooks/use-infinite-scroll.ts
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
export function useInfiniteScroll<T>({
fetchFn,
initialData,
initialCursor,
}: {
fetchFn: (cursor?: string) => Promise<{ items: T[]; nextCursor?: string }>;
initialData: T[];
initialCursor?: string;
}) {
const [items, setItems] = useState<T[]>(initialData);
const [cursor, setCursor] = useState(initialCursor);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const observerRef = useRef<IntersectionObserver | null>(null);
const loadMoreRef = useRef<HTMLDivElement | null>(null);
const loadMore = useCallback(async () => {
if (loading || !cursor) return;
setLoading(true);
setError(null);
try {
const result = await fetchFn(cursor);
setItems((prev) => [...prev, ...result.items]);
setCursor(result.nextCursor);
} catch {
setError("Failed to load more items. Please try again.");
} finally {
setLoading(false);
}
}, [cursor, fetchFn, loading]);
useEffect(() => {
if (observerRef.current) {
observerRef.current.disconnect();
}
observerRef.current = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
loadMore();
}
},
{ rootMargin: "200px" }
);
if (loadMoreRef.current) {
observerRef.current.observe(loadMoreRef.current);
}
return () => observerRef.current?.disconnect();
}, [loadMore]);
return { items, loading, error, hasMore: !!cursor, loadMoreRef };
}
Step 3: Server Component (Initial Data)
// app/posts/page.tsx
import { loadMorePosts } from "@/app/actions";
import { PostList } from "./post-list";
export default async function PostsPage() {
const { items, nextCursor } = await loadMorePosts();
return (
<main className="mx-auto max-w-7xl px-6 py-12">
<h1 className="text-3xl font-bold">All Posts</h1>
<PostList initialData={items} initialCursor={nextCursor} />
</main>
);
}
Step 4: Client Component (Infinite List)
// app/posts/post-list.tsx
"use client";
import { loadMorePosts } from "@/app/actions";
import { useInfiniteScroll } from "@/hooks/use-infinite-scroll";
type Post = {
id: string;
title: string;
excerpt: string;
createdAt: Date;
};
export function PostList({
initialData,
initialCursor,
}: {
initialData: Post[];
initialCursor?: string;
}) {
const { items, loading, error, hasMore, loadMoreRef } = useInfiniteScroll({
fetchFn: loadMorePosts,
initialData,
initialCursor,
});
return (
<div>
<div className="mt-8 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{items.map((post) => (
<article
key={post.id}
className="rounded-lg border p-6 dark:border-gray-700"
>
<h2 className="text-lg font-semibold">{post.title}</h2>
<p className="mt-2 text-sm text-gray-500">{post.excerpt}</p>
</article>
))}
</div>
{/* Sentinel element */}
<div ref={loadMoreRef} className="mt-8 flex justify-center">
{loading && (
<div className="h-8 w-8 animate-spin rounded-full border-2 border-gray-300 border-t-blue-600" />
)}
{error && (
<p className="text-sm text-red-500">{error}</p>
)}
{!hasMore && items.length > 0 && (
<p className="text-sm text-gray-500">No more posts</p>
)}
</div>
</div>
);
}
Step 5: Add a Manual Load More Button (Alternative)
Some users prefer a button instead of auto-loading:
{hasMore && !loading && (
<button
onClick={loadMore}
className="mt-8 rounded-lg bg-gray-100 px-6 py-3 text-sm font-medium hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700"
>
Load more
</button>
)}
Step 6: URL-Based Pagination Fallback
For SEO, provide paginated URLs as well:
// app/posts/page.tsx
import { loadMorePosts } from "@/app/actions";
import { PostList } from "./post-list";
export default async function PostsPage({
searchParams,
}: {
searchParams: Promise<{ page?: string }>;
}) {
const params = await searchParams;
const page = Number(params.page) || 1;
const { items, nextCursor } = await loadMorePosts();
return (
<main className="mx-auto max-w-7xl px-6 py-12">
<h1 className="text-3xl font-bold">All Posts</h1>
<PostList initialData={items} initialCursor={nextCursor} />
{/* Hidden pagination links for crawlers */}
<nav className="sr-only" aria-label="Pagination">
{page > 1 && <a href={`/posts?page=${page - 1}`}>Previous</a>}
<a href={`/posts?page=${page + 1}`}>Next</a>
</nav>
</main>
);
}
Performance Tips
- Use
rootMargin: "200px"to start loading before the user reaches the bottom - Render placeholder skeletons during loading for a smoother experience
- Deduplicate items by ID to prevent duplicates when data changes
- Consider virtualization (react-window or TanStack Virtual) for very large lists
Need a Custom Web Application?
We build high-performance web applications with advanced UX patterns like infinite scroll. Contact us for a consultation.