Cursor-based pagination is more reliable than offset-based for real-time data. No skipped or duplicated items when data changes.
Why Cursor-Based?
| Feature | Offset-Based | Cursor-Based |
|---|---|---|
| Consistent results | No (items shift) | Yes |
| Performance at scale | Degrades | Constant |
| Supports real-time | Poorly | Well |
| Implementation | Simpler | Slightly 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.