# 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.

```javascript
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.

```javascript
"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!
