Drizzle ORM pagination dengan cursor
Cursor-based pagination dengan Drizzle ORM — konsisten saat data berubah, scalable, dan tidak lambat seperti OFFSET di tabel besar.
Dipublikasikan 16 Juni 2026
OFFSET 50000 di tabel produk Tokopedia bikin DB ngos-ngosan. Cursor pagination pakai WHERE clause dengan index — query latency konstan berapapun page-nya. Snippet ini infinite scroll produk dengan Drizzle, dengan sort by tanggal yang sering ada duplicate.
Kode
// schema.ts
import { pgTable, bigserial, text, integer, timestamp } from "drizzle-orm/pg-core";
export const produk = pgTable("produk", {
id: bigserial("id", { mode: "number" }).primaryKey(),
nama: text("nama").notNull(),
harga: integer("harga").notNull(),
stok: integer("stok").notNull().default(0),
kategori_id: integer("kategori_id").notNull(),
created_at: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
});
// pagination.ts
import { and, desc, eq, lt, or, type SQL } from "drizzle-orm";
import { db } from "./db";
import { produk } from "./schema";
interface CursorPage<T> {
items: T[];
nextCursor: string | null;
}
interface Cursor {
created_at: string; // ISO string
id: number;
}
function encodeCursor(c: Cursor): string {
return Buffer.from(JSON.stringify(c)).toString("base64url");
}
function decodeCursor(s: string): Cursor {
return JSON.parse(Buffer.from(s, "base64url").toString("utf8")) as Cursor;
}
interface ListProdukOptions {
kategoriId?: number;
cursor?: string;
limit?: number;
}
export async function listProduk(
opts: ListProdukOptions = {}
): Promise<CursorPage<typeof produk.$inferSelect>> {
const limit = Math.min(opts.limit ?? 20, 100);
// Composite cursor: sort by (created_at DESC, id DESC)
// Filter: created_at < c.created_at OR (created_at = c.created_at AND id < c.id)
let cursorFilter: SQL | undefined;
if (opts.cursor) {
const c = decodeCursor(opts.cursor);
const cAt = new Date(c.created_at);
cursorFilter = or(
lt(produk.created_at, cAt),
and(eq(produk.created_at, cAt), lt(produk.id, c.id))
);
}
const filters: SQL[] = [];
if (opts.kategoriId) filters.push(eq(produk.kategori_id, opts.kategoriId));
if (cursorFilter) filters.push(cursorFilter);
// Ambil limit + 1 untuk tau ada page selanjutnya
const rows = await db
.select()
.from(produk)
.where(filters.length > 0 ? and(...filters) : undefined)
.orderBy(desc(produk.created_at), desc(produk.id))
.limit(limit + 1);
const hasNext = rows.length > limit;
const items = hasNext ? rows.slice(0, limit) : rows;
const nextCursor = hasNext
? encodeCursor({
created_at: items[items.length - 1].created_at.toISOString(),
id: items[items.length - 1].id,
})
: null;
return { items, nextCursor };
}
-- Index wajib supaya query cepat
CREATE INDEX produk_created_id_idx ON produk (created_at DESC, id DESC);
CREATE INDEX produk_kategori_created_idx ON produk (kategori_id, created_at DESC, id DESC);
Pemakaian
// Halaman pertama
const page1 = await listProduk({ kategoriId: 5, limit: 20 });
console.log(page1.items); // 20 produk terbaru
console.log(page1.nextCursor); // "eyJjcmVhdGV..."
// Halaman berikutnya
if (page1.nextCursor) {
const page2 = await listProduk({
kategoriId: 5,
limit: 20,
cursor: page1.nextCursor,
});
}
// API route Next.js untuk infinite scroll
// app/api/produk/route.ts
import { NextResponse } from "next/server";
import { listProduk } from "@/lib/pagination";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const cursor = searchParams.get("cursor") ?? undefined;
const kategoriId = searchParams.get("kategoriId");
const page = await listProduk({
cursor,
kategoriId: kategoriId ? Number(kategoriId) : undefined,
});
return NextResponse.json(page);
}
// React component infinite scroll
"use client";
import { useInfiniteQuery } from "@tanstack/react-query";
export function ListProdukInfinite({ kategoriId }: { kategoriId: number }) {
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey: ["produk", kategoriId],
queryFn: async ({ pageParam }) => {
const url = new URL("/api/produk", location.origin);
url.searchParams.set("kategoriId", String(kategoriId));
if (pageParam) url.searchParams.set("cursor", pageParam);
const r = await fetch(url);
return r.json();
},
getNextPageParam: (last) => last.nextCursor,
initialPageParam: undefined as string | undefined,
});
return (
<div>
{data?.pages.flatMap((p) => p.items).map((produk) => (
<article key={produk.id}>{produk.nama}</article>
))}
{hasNextPage && <button onClick={() => fetchNextPage()}>Lebih banyak</button>}
</div>
);
}
Kapan dipakai
- Infinite scroll feed (timeline, marketplace, notifikasi).
- Sync data ke client mobile dengan cursor — konsisten walau data berubah.
- Export ke file CSV chunked — process batch demi batch.
- API public yang return list besar — pagination wajib supaya gak OOM.
Catatan
- Composite cursor wajib kalau sort kolom non-unique. Pakai
(sort_value, id)sebagai cursor. Tanpa ini, row dengan timestamp sama bisa skip / duplicate. - Index harus match order —
created_at DESC, id DESCdi-query ya index-nya juga DESC. Postgres support index ASC dan DESC. - Encode cursor — JSON + base64. Jangan expose ID raw — leak DB structure. Atau pakai HMAC supaya cursor gak bisa di-craft client.
- Batch +1 — query
LIMIT 21untuk dapat 20 item + tahu ada page lanjut. Lebih efisien dari count(*). - Backward pagination — implementasi mirror dengan ASC order. Lebih ribet — biasanya app cukup forward-only.
Cursor pagination tidak support “loncat ke page 99”. Kalau user butuh jump-page (admin table), gabungkan offset + cursor: page kecil pakai offset, page besar pakai cursor. Atau pakai search-after pattern.
# tags
drizzleormpaginationcursorpostgres
Ditulis oleh Asti Larasati · 16 Juni 2026