Type something to search...
Building Infinite Scroll and Paginated Lists in Next.js

Building Infinite Scroll and Paginated Lists in Next.js

Almost every app eventually has a list that's too long to render in one go: blog posts, products, comments, search results, activity feeds. You have two classic answers. Numbered pagination gives users stable URLs and a sense of where they are. Infinite scroll keeps people moving through a feed without clicking. Both are easy to build badly in Next.js, usually by pushing all the work into the browser and losing server rendering along the way.

This post builds both patterns in the App Router. You'll start with a small data layer that supports offset and cursor queries, then build a server-rendered paginated list driven by searchParams, then an infinite feed that renders its first page on the server and loads the rest with a Route Handler and IntersectionObserver. Along the way I'll cover the details that tend to bite: invalid page numbers, duplicate items, error states, SEO, and what happens when the user presses Back.

Offset vs Cursor Pagination

Before writing any UI, decide how you'll ask the database for "the next chunk". There are two approaches.

Offset pagination says "skip 40 rows, give me 20". It maps directly to page numbers: page 3 with 20 per page is OFFSET 40 LIMIT 20.

Cursor pagination says "give me 20 rows that come after this one". The cursor is usually the ID (or timestamp plus ID) of the last item you already have: WHERE id < 1234 ORDER BY id DESC LIMIT 20.

OffsetCursor
Jump to page NYesNo, only next/previous
Total page countEasy (needs a COUNT)Not needed, often skipped
Performance on deep pagesGets slower: the database still walks the skipped rowsConstant, uses the index
Stable when new rows are insertedNo, items shift and duplicate across pagesYes
Best fitNumbered pagination, admin tablesInfinite scroll, feeds, "load more"

The rule of thumb: numbered pages want offsets, infinite scroll wants cursors. If you use offsets for an infinite feed, a new post arriving at the top pushes everything down by one, and the user sees the last item of the previous batch again at the start of the next one.

A Small Data Layer

The examples share one module. To keep things runnable it uses an in-memory array, but each function has the shape you'd implement against a real database, and the comments show the equivalent SQL.

// lib/posts.ts
export type Post = {
  id: number;
  title: string;
  excerpt: string;
  createdAt: string;
};

// Stand-in data: 95 posts, newest (highest id) first.
const ALL_POSTS: Post[] = Array.from({ length: 95 }, (_, i) => {
  const id = 95 - i;
  return {
    id,
    title: `Post number ${id}`,
    excerpt: `A short summary of post ${id}.`,
    createdAt: new Date(Date.UTC(2026, 0, 1) + id * 86_400_000).toISOString(),
  };
});

// Offset pagination.
// SQL: SELECT ... ORDER BY id DESC LIMIT $pageSize OFFSET ($page - 1) * $pageSize
export async function getPostsPage(page: number, pageSize: number) {
  const start = (page - 1) * pageSize;
  const posts = ALL_POSTS.slice(start, start + pageSize);
  const total = ALL_POSTS.length; // SQL: SELECT COUNT(*) FROM posts
  return { posts, total, totalPages: Math.ceil(total / pageSize) };
}

// Cursor pagination.
// SQL: SELECT ... WHERE ($cursor IS NULL OR id < $cursor)
//      ORDER BY id DESC LIMIT $limit + 1
export async function getPostsAfter(cursor: number | null, limit: number) {
  const rows = ALL_POSTS.filter((p) => cursor === null || p.id < cursor).slice(
    0,
    limit + 1,
  );
  const hasMore = rows.length > limit;
  const posts = hasMore ? rows.slice(0, limit) : rows;
  const nextCursor = hasMore ? posts[posts.length - 1].id : null;
  return { posts, nextCursor };
}

Two details worth copying into your real implementation:

  • getPostsAfter fetches limit + 1 rows. If the extra row exists, there's another page, and you know it without a separate count query. The extra row is dropped before returning.
  • nextCursor is null when there's nothing left. The client uses that as its "stop loading" signal.

If you sort by something that isn't unique, like createdAt, make the cursor a pair (createdAt plus id) and compare on both. Otherwise two rows with the same timestamp can be skipped or repeated at a page boundary.

Numbered Pagination with searchParams

The simplest robust pagination is a Server Component that reads ?page= from the URL. Every page has a real URL, the HTML is complete on first load, and there's no client JavaScript for the list itself.

// app/posts/page.tsx
import Link from "next/link";
import { notFound } from "next/navigation";
import { getPostsPage } from "@/lib/posts";
import { Pagination } from "./pagination";

const PAGE_SIZE = 10;

function parsePage(value: string | string[] | undefined): number {
  const raw = Array.isArray(value) ? value[0] : value;
  const page = Number(raw ?? "1");
  return Number.isInteger(page) && page >= 1 ? page : 1;
}

export default async function PostsPage({
  searchParams,
}: {
  searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}) {
  const { page: pageParam } = await searchParams;
  const page = parsePage(pageParam);
  const { posts, totalPages } = await getPostsPage(page, PAGE_SIZE);

  if (totalPages > 0 && page > totalPages) {
    notFound();
  }

  return (
    <main>
      <h1>All posts</h1>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>
            <Link href={`/posts/${post.id}`}>{post.title}</Link>
            <p>{post.excerpt}</p>
          </li>
        ))}
      </ul>
      <Pagination currentPage={page} totalPages={totalPages} />
    </main>
  );
}

What's going on here:

  • searchParams is a promise in current versions of Next.js, so you await it. Its values can be a string, an array (for ?page=1&page=2), or undefined, which is why parsePage normalizes all three.
  • Garbage like ?page=abc or ?page=-4 falls back to page 1 instead of crashing or querying with a negative offset.
  • A page number past the end calls notFound(), which renders your nearest not-found.tsx with a 404 status. That's better for SEO than an empty list with a 200.

Reading searchParams makes the route render at request time. For most lists backed by a database that's what you want. If your list is static content (like this blog), see the static variant further down.

The Pagination Component

The pagination control is also a Server Component. It just renders links, so it needs no "use client".

// app/posts/pagination.tsx
import Link from "next/link";

function pageHref(page: number) {
  return page === 1 ? "/posts" : `/posts?page=${page}`;
}

function getPageList(current: number, total: number): (number | "gap")[] {
  const pages = new Set([1, total, current - 1, current, current + 1]);
  const sorted = [...pages]
    .filter((p) => p >= 1 && p <= total)
    .sort((a, b) => a - b);

  const result: (number | "gap")[] = [];
  for (let i = 0; i < sorted.length; i++) {
    if (i > 0 && sorted[i] - sorted[i - 1] > 1) result.push("gap");
    result.push(sorted[i]);
  }
  return result;
}

export function Pagination({
  currentPage,
  totalPages,
}: {
  currentPage: number;
  totalPages: number;
}) {
  if (totalPages <= 1) return null;

  return (
    <nav aria-label="Pagination">
      <ul className="flex items-center gap-2">
        {currentPage > 1 && (
          <li>
            <Link href={pageHref(currentPage - 1)} rel="prev">
              Previous
            </Link>
          </li>
        )}

        {getPageList(currentPage, totalPages).map((item, i) =>
          item === "gap" ? (
            <li key={`gap-${i}`} aria-hidden="true">
              …
            </li>
          ) : (
            <li key={item}>
              <Link
                href={pageHref(item)}
                aria-current={item === currentPage ? "page" : undefined}
                className={item === currentPage ? "font-bold underline" : ""}
              >
                {item}
              </Link>
            </li>
          ),
        )}

        {currentPage < totalPages && (
          <li>
            <Link href={pageHref(currentPage + 1)} rel="next">
              Next
            </Link>
          </li>
        )}
      </ul>
    </nav>
  );
}

getPageList produces a compact list like 1 … 4 5 6 … 10 so you don't render 200 links for a big table. aria-current="page" tells screen readers which page is active, and page 1 links to the bare /posts so you don't end up with two URLs for the same content.

Because these are Link components, navigation between pages is a client-side transition. Links in the viewport are prefetched in production, so clicking "Next" often feels instant. If you want the page to stay where it is rather than jump back to the top when the list changes (useful when the pagination sits above a filter bar), pass scroll={false} to the links. For more on how Link handles scrolling and prefetching, see the Link component guide.

Adding a Loading State

Navigating from page 2 to page 3 triggers a new server render. Add a loading.tsx next to the page and Next.js wraps the page in a Suspense boundary, showing your fallback while the next page's data loads:

// app/posts/loading.tsx
export default function Loading() {
  return (
    <main>
      <h1>All posts</h1>
      <p aria-busy="true">Loading posts…</p>
    </main>
  );
}

Static Pagination for Content Sites

If the list only changes when you deploy, as with Markdown blog posts, you can prerender every page at build time by moving the page number into a path segment:

// app/posts/page/[page]/page.tsx
import Link from "next/link";
import { notFound } from "next/navigation";
import { getPostsPage } from "@/lib/posts";

const PAGE_SIZE = 10;

export async function generateStaticParams() {
  const { totalPages } = await getPostsPage(1, PAGE_SIZE);
  return Array.from({ length: totalPages }, (_, i) => ({
    page: String(i + 1),
  }));
}

export default async function PostsPageN({
  params,
}: {
  params: Promise<{ page: string }>;
}) {
  const { page: pageParam } = await params;
  const page = Number(pageParam);
  const { posts, totalPages } = await getPostsPage(page, PAGE_SIZE);

  if (!Number.isInteger(page) || page < 1 || page > totalPages) notFound();

  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>
          <Link href={`/posts/${post.id}`}>{post.title}</Link>
        </li>
      ))}
    </ul>
  );
}

generateStaticParams returns one entry per page, so /posts/page/1 through /posts/page/10 are all built ahead of time and served as static files. This is exactly how this site's own /blog/page/[page] route works. The trade-off is that adding content requires a rebuild (or a revalidation strategy), which is fine for a blog and wrong for a store with live inventory.

Infinite Scroll

Infinite scroll has three parts:

  1. The server renders the first batch, so the page has real content on first paint and for crawlers.
  2. A small endpoint returns the next batch for a given cursor.
  3. A Client Component watches a sentinel element near the bottom of the list and requests more when it comes into view.

The Endpoint

A Route Handler is a good fit for "give me the next batch". It's a plain GET, it can be called in parallel or cancelled, and you can cache it later if you need to.

// app/api/posts/route.ts
import type { NextRequest } from "next/server";
import { getPostsAfter } from "@/lib/posts";

const BATCH_SIZE = 12;

export async function GET(request: NextRequest) {
  const cursorParam = request.nextUrl.searchParams.get("cursor");
  const cursor = cursorParam === null ? null : Number(cursorParam);

  if (cursor !== null && !Number.isInteger(cursor)) {
    return Response.json({ error: "Invalid cursor" }, { status: 400 });
  }

  const data = await getPostsAfter(cursor, BATCH_SIZE);
  return Response.json(data);
}

The handler validates the cursor and returns { posts, nextCursor }. Never trust a cursor from the client: it's user input like any other query parameter. If you're new to Route Handlers, the Route Handlers guide covers them in depth.

The Page

The page is a Server Component that fetches the first batch with the same function the endpoint uses, then hands it to the client:

// app/feed/page.tsx
import { getPostsAfter } from "@/lib/posts";
import { InfiniteFeed } from "./infinite-feed";

export default async function FeedPage() {
  const { posts, nextCursor } = await getPostsAfter(null, 12);

  return (
    <main>
      <h1>Latest posts</h1>
      <InfiniteFeed initialPosts={posts} initialCursor={nextCursor} />
    </main>
  );
}

Passing initialPosts from the server means the first 12 items are in the HTML. Without this step you'd ship an empty page plus a spinner, which hurts both perceived speed and Largest Contentful Paint.

The Client Component

// app/feed/infinite-feed.tsx
"use client";

import Link from "next/link";
import { useCallback, useEffect, useRef, useState } from "react";
import type { Post } from "@/lib/posts";

type Status = "idle" | "loading" | "error";

export function InfiniteFeed({
  initialPosts,
  initialCursor,
}: {
  initialPosts: Post[];
  initialCursor: number | null;
}) {
  const [posts, setPosts] = useState(initialPosts);
  const [cursor, setCursor] = useState(initialCursor);
  const [status, setStatus] = useState<Status>("idle");
  const sentinelRef = useRef<HTMLDivElement>(null);
  const inFlight = useRef(false);

  const loadMore = useCallback(async () => {
    if (inFlight.current || cursor === null) return;
    inFlight.current = true;
    setStatus("loading");

    try {
      const res = await fetch(`/api/posts?cursor=${cursor}`);
      if (!res.ok) throw new Error(`Request failed with ${res.status}`);
      const data: { posts: Post[]; nextCursor: number | null } =
        await res.json();

      setPosts((prev) => {
        const seen = new Set(prev.map((p) => p.id));
        return [...prev, ...data.posts.filter((p) => !seen.has(p.id))];
      });
      setCursor(data.nextCursor);
      setStatus("idle");
    } catch {
      setStatus("error");
    } finally {
      inFlight.current = false;
    }
  }, [cursor]);

  useEffect(() => {
    const node = sentinelRef.current;
    if (!node || cursor === null || status === "error") return;

    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0]?.isIntersecting) loadMore();
      },
      { rootMargin: "400px 0px" },
    );

    observer.observe(node);
    return () => observer.disconnect();
  }, [loadMore, cursor, status]);

  return (
    <>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>
            <Link href={`/posts/${post.id}`}>{post.title}</Link>
            <p>{post.excerpt}</p>
          </li>
        ))}
      </ul>

      <div ref={sentinelRef} aria-hidden="true" />

      <div aria-live="polite">
        {status === "loading" && <p>Loading more…</p>}
        {status === "error" && (
          <p>
            Couldn&apos;t load more posts.{" "}
            <button type="button" onClick={loadMore}>
              Try again
            </button>
          </p>
        )}
        {cursor === null && <p>You&apos;ve reached the end.</p>}
      </div>

      {cursor !== null && status === "idle" && (
        <button type="button" onClick={loadMore}>
          Load more
        </button>
      )}
    </>
  );
}

There's a fair amount going on, so let's walk through it.

The sentinel. An empty div sits after the last item. IntersectionObserver fires when it enters the viewport, and rootMargin: "400px 0px" expands the trigger zone by 400px so the next batch starts loading before the user actually hits the bottom. Tune that number to your item height and API latency.

The in-flight guard. inFlight is a ref, not state, because it has to change synchronously. Scroll events and observer callbacks can fire several times before React re-renders; a state flag would still read false in those calls and you'd fire duplicate requests.

Re-observing after each load. The effect depends on cursor and status, so it creates a fresh observer after every batch. A new observer reports the sentinel's current state immediately. If the user has a tall screen and the sentinel is still visible after a batch loads, the next batch loads too, until the viewport is filled. Without this, a short first page on a large monitor can leave the feed stuck.

Deduplication. Filtering out IDs you already have is cheap insurance. With cursor pagination duplicates are rare, but retries and double clicks can still produce them, and duplicate React keys cause confusing rendering bugs.

Errors stop the loop. When a request fails, the effect bails out instead of re-observing, so you don't hammer a failing API every time the user nudges the scroll position. The user gets an explicit "Try again" button.

A real button. The "Load more" button isn't just a fallback for old browsers. Keyboard and screen reader users can't "scroll to trigger", and some users simply want control. The aria-live region announces loading, error, and end states.

Using a Server Action Instead of a Route Handler

You can also load the next batch with a Server Function:

// app/feed/actions.ts
"use server";

import { getPostsAfter } from "@/lib/posts";

export async function loadMorePosts(cursor: number) {
  if (!Number.isInteger(cursor)) throw new Error("Invalid cursor");
  return getPostsAfter(cursor, 12);
}

In the client you'd replace the fetch call with const data = await loadMorePosts(cursor). It saves you writing a URL and parsing JSON, and you get end-to-end types for free.

Be aware of the trade-offs, though. Server Actions are designed for mutations: they're always POST requests, they can't be cached like a GET, and Next.js dispatches them one at a time per client, so a slow "load more" will queue behind any other action the user triggers (like a "like" button). For a read-heavy feed, the Route Handler is usually the better default.

Using a Data Library

If your app already uses TanStack Query or SWR, both have built-in infinite loading (useInfiniteQuery and useSWRInfinite) that handle caching, retries, and deduplication for you. You still render the first page on the server and pass it in as initial data. See using SWR and TanStack Query alongside Server Components for how to wire that up.

Pitfalls to Plan For

SEO

Search engine crawlers don't scroll and don't click "Load more". With pure infinite scroll, anything beyond the first batch is effectively invisible to them. If the list contains content you want indexed, provide a crawlable path to every item:

  • Keep a paginated version (/posts?page=2 or /posts/page/2) and link to it, even if it's only in the footer.
  • Or make sure every item is reachable through other links: categories, tags, a sitemap.

The Back Button

The user scrolls through 80 items, clicks one, then presses Back. The feed component remounts with only initialPosts, and the user is dropped at the top of a 12-item list. This is the most common complaint about infinite scroll.

There are a few ways to soften it:

  • Open detail views in a modal using intercepting routes, so the feed never unmounts. See intercepting routes for modals.
  • Record how far the user got in the URL with window.history.replaceState (for example ?loaded=4) and have the server render that many batches on return. Next.js integrates native replaceState with its router, so useSearchParams stays in sync.
  • Use a client cache (TanStack Query) that keeps the loaded pages in memory across navigations.
  • Or accept it and use numbered pagination for lists where users frequently open items and come back, such as search results and product grids.

Very Long Lists

After a few hundred items, the DOM itself becomes the bottleneck: scrolling janks and memory grows. If users routinely load that much, virtualize the list with a library like TanStack Virtual so only visible rows are rendered, or cap the feed and switch to "View all on page 2" links.

Footers

Infinite scroll and a page footer don't mix: the footer keeps running away from the user. Either drop the footer on feed pages, or switch to a "Load more" button after a few automatic batches.

Choosing Between the Two

Use casePattern
Search results, product listings, admin tablesNumbered pagination
Blog archive, documentation indexNumbered pagination (static)
Social feeds, activity streams, image galleriesInfinite scroll with cursor
Comments under an article"Load more" button with cursor
Anything users need to share or bookmark by positionNumbered pagination

When in doubt, start with numbered pagination. It's less code, it's fully server-rendered, it's accessible by default, and every state has a URL.

Conclusion

Both patterns work best when the server does the first render. For numbered pagination, read and validate searchParams in a Server Component, use offset queries, return a 404 for pages that don't exist, and render plain Link components. For infinite scroll, render the first batch on the server, expose a cursor-based GET endpoint, and drive it from a small Client Component with an IntersectionObserver, an in-flight guard, deduplication, and a visible "Load more" button. Decide early how you'll handle SEO and the Back button, because those are what users and crawlers notice, not the scroll trigger itself.

Tags :
Share :

Related Posts

A Deep Dive into next.config Options Every Developer Should Know

A Deep Dive into next.config Options Every Developer Should Know

next.config.ts is the one file every Next.js project has and almost nobody reads end to end. It starts as an empty object, then slowly collects a r

Continue Reading
Adding JSON-LD Structured Data to Next.js Pages for Rich Search Results

Adding JSON-LD Structured Data to Next.js Pages for Rich Search Results

Search engines are good at reading pages, but they still guess. Is "4.7" a rating or a version number? Is that date when the article was published or

Continue Reading
Adding Page Transitions and Animations to Next.js with Framer Motion

Adding Page Transitions and Animations to Next.js with Framer Motion

Animation is one of the easiest ways to make an app feel polished, and one of the easiest ways to make it feel slow. A subtle fade when a page loads,

Continue Reading