karawaci.kode

← Semua snippet

TypeScript Menengah Database

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 ordercreated_at DESC, id DESC di-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 21 untuk 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