Skip to main content
Back to Blog
Tutorials
3 min read
November 29, 2024

How to Implement Infinite Scroll in Next.js

Build infinite scroll with intersection observer in Next.js. Load more content automatically as users scroll, with loading states and error handling.

Ryel Banfield

Founder & Lead Developer

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.

infinite scrollpaginationNext.jsReacttutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles