Implementing Cursor-Based Pagination in Next.js + Drizzle ORM

Pagination helps load data efficiently without fetching everything at once.
While offset-based pagination (using page and limit) is simple, it can become inefficient with large datasets.
That’s where cursor-based pagination comes in, it’s faster, more stable, and widely used in APIs like Twitter or GitHub.
What Is Cursor-Based Pagination?
Instead of asking:
“Give me page 2”
We say:
“Give me the next 3 items after this record (cursor).”
Each response includes:
data→ list of resultsnext_cursor→ the next record’s IDhasNextPage→ whether more data exists
This makes it more reliable when data is constantly changing.
Backend — Next.js API Route + Drizzle ORM
Let’s start with the backend logic.
We’ll handle both initial and subsequent requests from the frontend.
When no cursor is provided → fetch the first batch.
When cursor exists → fetch results after that cursor.
import { posts } from "@/db/schema";
import db from "@/lib/drizzle";
import { desc, lte } from "drizzle-orm";
import { NextRequest, NextResponse } from "next/server";
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const cursor = Number(searchParams.get("cursor")) || null;
const limit = Number(searchParams.get("page_size")) || 3;
// Fetch one extra to check if more results exist
const effectiveLimit = limit + 1;
let hasNextPage = false;
let next_cursor = null;
const data = await db
.select()
.from(posts)
.where(cursor ? lte(posts.id, cursor) : undefined)
.orderBy(desc(posts.id))
.limit(effectiveLimit);
// If we got more data than limit, that means there’s a next page
if (data.length > limit) {
hasNextPage = true;
next_cursor = data[data.length - 1].id;
data.pop(); // remove the extra fetched item
}
return NextResponse.json({
data,
next_cursor,
hasNextPage,
message: "Fetched successfully",
});
}
Backend logic summary:
Fetch
(limit + 1)rows.If extra data exists →
hasNextPage = true.Remove the extra record and send the remaining ones.
Return
next_cursorfor the next request.
Frontend - React (Next.js Client Component)
The frontend keeps track of:
datacursorhasNextPageloading state
Each time we fetch, we append new results to the existing list.
"use client";
import axios from "axios";
import { useEffect, useState } from "react";
interface Post {
id: number;
title: string;
description: string;
}
export default function Page() {
const [data, setData] = useState<Post[]>([]);
const [cursor, setCursor] = useState<number | null>(null);
const [hasNextPage, setHasNextPage] = useState(false);
const [loading, setLoading] = useState(false);
async function fetchPosts(cursor: number | null, page_size = 3) {
try {
setLoading(true);
const res = await axios.get(`/api/fetchPosts?cursor=${cursor}&page_size=${page_size}`);
setData((prev) => [...prev, ...res.data.data]);
setCursor(res.data.next_cursor);
setHasNextPage(res.data.hasNextPage);
} finally {
setLoading(false);
}
}
useEffect(() => {
fetchPosts(null);
}, []);
return (
<div>
{data.map((post) => (
<div key={post.id} className="bg-gray-100 p-3 mb-2 rounded-md">
<h2 className="font-semibold text-lg">{post.title}</h2>
<p className="text-sm text-gray-700">{post.description}</p>
</div>
))}
{hasNextPage && (
<button
disabled={loading}
onClick={() => fetchPosts(cursor)}
className="bg-blue-500 text-white px-4 py-2 rounded-md"
>
{loading ? "Loading..." : "Load More"}
</button>
)}
</div>
);
}
Frontend logic summary:
On first render → fetch initial data.
On “Load More” click → send the last
cursor.Append new results to the state.
Hide the button if there are no more pages.
Why Cursor Pagination Is Better
| Feature | Offset Pagination | Cursor Pagination |
| Speed | Slower for large data | Faster and consistent |
| Data stability | Can skip or duplicate data | Reliable order |
| Implementation | Easier | Slightly complex |
| Best for | Static data | Dynamic feeds (e.g. social media, posts) |
Cursor pagination is ideal for real-time apps like feeds, dashboards, or infinite scrolls.
Final Thoughts
Cursor-based pagination gives you both performance and consistency.
It ensures you always fetch the right data even when your dataset changes frequently.
If you’re building a product with constantly updating data, like posts, comments, or stock market lists, this approach will scale much better than traditional offset-based pagination.
💬 Have any questions or improvements? Drop a comment below!

