Skip to main content

Command Palette

Search for a command to run...

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

Updated
3 min read
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 results

  • next_cursor → the next record’s ID

  • hasNextPage → 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_cursor for the next request.


Frontend - React (Next.js Client Component)

The frontend keeps track of:

  • data

  • cursor

  • hasNextPage

  • loading 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

FeatureOffset PaginationCursor Pagination
SpeedSlower for large dataFaster and consistent
Data stabilityCan skip or duplicate dataReliable order
ImplementationEasierSlightly complex
Best forStatic dataDynamic 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!

More from this blog

G

Gaurab Wagle

12 posts