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

How to Implement Cursor-Based Pagination in Next.js

Implement cursor-based pagination for consistent, performant data loading in Next.js with Drizzle ORM.

Ryel Banfield

Founder & Lead Developer

Cursor-based pagination is more reliable than offset-based for real-time data. No skipped or duplicated items when data changes.

Why Cursor-Based?

FeatureOffset-BasedCursor-Based
Consistent resultsNo (items shift)Yes
Performance at scaleDegradesConstant
Supports real-timePoorlyWell
ImplementationSimplerSlightly more complex

Step 1: API Route with Cursor Pagination

// app/api/posts/route.ts
import { NextResponse } from "next/server";
import { db } from "@/db";
import { posts } from "@/db/schema";
import { gt, lt, desc, asc, and, eq } from "drizzle-orm";

export async function GET(req: Request) {
  const { searchParams } = new URL(req.url);
  const cursor = searchParams.get("cursor");
  const limit = Math.min(Number(searchParams.get("limit") || 20), 100);
  const direction = searchParams.get("direction") || "next";
  const status = searchParams.get("status");

  const conditions = [];

  // Filter conditions
  if (status) conditions.push(eq(posts.status, status));

  // Cursor condition
  if (cursor) {
    if (direction === "next") {
      conditions.push(lt(posts.createdAt, new Date(cursor)));
    } else {
      conditions.push(gt(posts.createdAt, new Date(cursor)));
    }
  }

  const results = await db
    .select()
    .from(posts)
    .where(conditions.length > 0 ? and(...conditions) : undefined)
    .orderBy(direction === "next" ? desc(posts.createdAt) : asc(posts.createdAt))
    .limit(limit + 1); // Fetch one extra to check if there are more

  // If going backwards, reverse the results
  if (direction === "prev") results.reverse();

  const hasMore = results.length > limit;
  const items = hasMore ? results.slice(0, limit) : results;

  return NextResponse.json({
    items,
    nextCursor: hasMore
      ? items[items.length - 1].createdAt.toISOString()
      : null,
    prevCursor: items.length > 0
      ? items[0].createdAt.toISOString()
      : null,
  });
}

Step 2: Pagination Hook

"use client";

import { useState, useCallback } from "react";

interface PaginatedResponse<T> {
  items: T[];
  nextCursor: string | null;
  prevCursor: string | null;
}

export function useCursorPagination<T>(
  fetchUrl: string,
  initialData?: PaginatedResponse<T>
) {
  const [data, setData] = useState<PaginatedResponse<T>>(
    initialData || { items: [], nextCursor: null, prevCursor: null }
  );
  const [isLoading, setIsLoading] = useState(false);

  const fetchPage = useCallback(
    async (cursor: string | null, direction: "next" | "prev") => {
      setIsLoading(true);
      try {
        const params = new URLSearchParams();
        if (cursor) params.set("cursor", cursor);
        params.set("direction", direction);

        const res = await fetch(`${fetchUrl}?${params}`);
        const json: PaginatedResponse<T> = await res.json();
        setData(json);
      } finally {
        setIsLoading(false);
      }
    },
    [fetchUrl]
  );

  const nextPage = useCallback(
    () => data.nextCursor && fetchPage(data.nextCursor, "next"),
    [data.nextCursor, fetchPage]
  );

  const prevPage = useCallback(
    () => data.prevCursor && fetchPage(data.prevCursor, "prev"),
    [data.prevCursor, fetchPage]
  );

  return {
    items: data.items,
    hasNext: !!data.nextCursor,
    hasPrev: !!data.prevCursor,
    nextPage,
    prevPage,
    isLoading,
  };
}

Step 3: Paginated List Component

"use client";

import { useCursorPagination } from "@/hooks/useCursorPagination";
import { ChevronLeft, ChevronRight, Loader2 } from "lucide-react";

interface Post {
  id: string;
  title: string;
  excerpt: string;
  createdAt: string;
}

export function PaginatedPosts() {
  const { items, hasNext, hasPrev, nextPage, prevPage, isLoading } =
    useCursorPagination<Post>("/api/posts");

  return (
    <div>
      {isLoading && (
        <div className="flex justify-center py-8">
          <Loader2 className="h-6 w-6 animate-spin text-gray-400" />
        </div>
      )}

      {!isLoading && (
        <div className="space-y-4">
          {items.map((post) => (
            <article
              key={post.id}
              className="rounded-lg border p-4 dark:border-gray-700"
            >
              <h2 className="font-semibold">{post.title}</h2>
              <p className="mt-1 text-sm text-gray-500">{post.excerpt}</p>
              <time className="mt-2 block text-xs text-gray-400">
                {new Date(post.createdAt).toLocaleDateString()}
              </time>
            </article>
          ))}
        </div>
      )}

      <div className="mt-6 flex items-center justify-between">
        <button
          onClick={prevPage}
          disabled={!hasPrev || isLoading}
          className="flex items-center gap-1 rounded-lg border px-3 py-2 text-sm disabled:opacity-50 dark:border-gray-700"
        >
          <ChevronLeft className="h-4 w-4" /> Previous
        </button>
        <button
          onClick={nextPage}
          disabled={!hasNext || isLoading}
          className="flex items-center gap-1 rounded-lg border px-3 py-2 text-sm disabled:opacity-50 dark:border-gray-700"
        >
          Next <ChevronRight className="h-4 w-4" />
        </button>
      </div>
    </div>
  );
}

Step 4: Infinite Scroll with Cursor

"use client";

import { useState, useEffect, useRef, useCallback } from "react";

export function InfiniteCursorScroll<T extends { id: string }>({
  fetchUrl,
  renderItem,
}: {
  fetchUrl: string;
  renderItem: (item: T) => React.ReactNode;
}) {
  const [items, setItems] = useState<T[]>([]);
  const [cursor, setCursor] = useState<string | null>(null);
  const [hasMore, setHasMore] = useState(true);
  const [isLoading, setIsLoading] = useState(false);
  const observerRef = useRef<HTMLDivElement>(null);

  const loadMore = useCallback(async () => {
    if (isLoading || !hasMore) return;
    setIsLoading(true);

    const params = new URLSearchParams();
    if (cursor) params.set("cursor", cursor);
    params.set("direction", "next");

    const res = await fetch(`${fetchUrl}?${params}`);
    const data = await res.json();

    setItems((prev) => [...prev, ...data.items]);
    setCursor(data.nextCursor);
    setHasMore(!!data.nextCursor);
    setIsLoading(false);
  }, [fetchUrl, cursor, hasMore, isLoading]);

  useEffect(() => {
    loadMore();
  }, []);

  useEffect(() => {
    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting) loadMore();
      },
      { threshold: 0.1 }
    );

    if (observerRef.current) observer.observe(observerRef.current);
    return () => observer.disconnect();
  }, [loadMore]);

  return (
    <div>
      {items.map(renderItem)}
      <div ref={observerRef} className="h-10">
        {isLoading && (
          <p className="text-center text-sm text-gray-500">Loading more...</p>
        )}
      </div>
    </div>
  );
}

Need Scalable Data Loading?

We build performant web applications with real-time data, pagination, and optimized database queries. Contact us to discuss your project.

paginationcursordatabaseAPINext.jstutorial

Ready to Start Your Project?

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

Get in Touch

Related Articles