Jika endpoint list di Next.js App Router makin lambat saat page bertambah besar, penyebabnya sering bukan di React atau route handler, tetapi di pola query database. LIMIT/OFFSET terlihat sederhana, namun performanya menurun saat data membesar karena database perlu melewati semakin banyak baris sebelum mengembalikan hasil.
Solusi yang lebih cocok untuk dataset besar adalah cursor pagination. Dengan pendekatan ini, query tidak lagi berkata “ambil halaman ke-500”, tetapi “ambil 20 baris setelah item terakhir yang sudah saya punya”. Hasilnya lebih stabil, beban database lebih rendah, dan halaman admin atau API list tidak terasa berat di page tinggi.
Mengapa LIMIT/OFFSET Menjadi Lambat
Contoh query offset pagination biasanya seperti ini:
SELECT id, created_at, title
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 10000;Masalahnya, OFFSET 10000 tidak berarti database langsung melompat ke baris ke-10001 secara gratis. Dalam banyak kasus, database tetap harus:
- menemukan urutan data sesuai
ORDER BY, - melewati ribuan baris terlebih dahulu,
- baru mengambil 20 baris yang diminta.
Gejala nyata yang biasanya muncul:
- endpoint
/api/posts?page=200jauh lebih lambat daripada page 1, - CPU dan I/O database naik saat user membuka halaman admin dalam jumlah besar,
- latensi API list tidak stabil, terutama untuk data yang terus bertambah.
Semakin tinggi page, semakin mahal biaya query. Ini sebabnya offset pagination cocok untuk dataset kecil atau kebutuhan sederhana, tetapi mulai menjadi bottleneck saat tabel membesar.
Kapan Cursor Pagination Lebih Tepat
Cursor pagination lebih tepat jika:
- tabel terus bertambah besar,
- hasil diurutkan berdasarkan kolom yang konsisten,
- API lebih sering digunakan untuk “load next page” daripada lompat bebas ke page acak,
- performa dan stabilitas lebih penting daripada nomor halaman absolut.
Cursor pagination kurang ideal jika UI benar-benar membutuhkan navigasi seperti “langsung ke halaman 87”. Dalam skenario itu, offset pagination lebih mudah dipakai, walau lebih mahal. Untuk admin, feed, audit log, order list, dan API publik dengan traffic tinggi, cursor biasanya lebih cocok.
Prinsip Dasar Cursor Pagination yang Stabil
Kunci cursor pagination adalah urutan yang konsisten dan unik. Kesalahan umum adalah mengurutkan hanya dengan created_at DESC. Jika banyak baris memiliki timestamp yang sama, hasil pagination bisa duplikat atau ada data yang terlewat.
Gunakan urutan gabungan seperti ini:
ORDER BY created_at DESC, id DESCDengan begitu:
created_atmenentukan urutan utama,idmenjadi tie-breaker agar urutan tetap unik dan deterministik.
Cursor biasanya menyimpan nilai terakhir dari kolom sorting tersebut, misalnya:
{ "created_at": "2024-01-10T12:00:00.000Z", "id": 98765 }Saat meminta halaman berikutnya, query menjadi:
SELECT id, created_at, title
FROM posts
WHERE (created_at < $1)
OR (created_at = $1 AND id < $2)
ORDER BY created_at DESC, id DESC
LIMIT 21;LIMIT 21 dipakai jika ukuran halaman 20 agar kita bisa mendeteksi apakah masih ada halaman berikutnya. Ambil 20 data pertama, lalu gunakan baris ke-21 sebagai sinyal hasNextPage.
Desain Kolom dan Index yang Dibutuhkan
Pilih kolom sort yang tepat
Kolom untuk cursor harus memenuhi karakter berikut:
- sering dipakai untuk urutan list,
- nilainya relatif stabil,
- bisa diindeks dengan baik,
- lebih baik jika monoton atau setidaknya mudah dipakai untuk urutan konsisten.
Pilihan umum:
created_at+iduntuk feed atau data terbaru,updated_at+idjika daftar harus mengikuti perubahan terakhir,idsaja jikaidcukup merepresentasikan urutan dan sifatnya meningkat.
Hindari memakai kolom yang sering berubah drastis sebagai sort utama jika Anda ingin perilaku pagination yang mudah diprediksi. Saat nilai sort berubah di tengah navigasi, item bisa berpindah posisi.
Index yang mendukung ORDER BY dan WHERE
Jika query Anda memakai:
WHERE (created_at < ?)
OR (created_at = ? AND id < ?)
ORDER BY created_at DESC, id DESC
LIMIT 21maka index gabungan sangat penting:
CREATE INDEX idx_posts_created_id_desc
ON posts (created_at DESC, id DESC);Pada banyak database, urutan index dan urutan query harus selaras agar planner bisa melakukan index scan yang efisien. Jika ada filter tambahan, pertimbangkan index komposit yang sesuai dengan pola query nyata.
Contoh jika hanya menampilkan post aktif:
SELECT id, created_at, title
FROM posts
WHERE status = 'published'
AND ((created_at < $1)
OR (created_at = $1 AND id < $2))
ORDER BY created_at DESC, id DESC
LIMIT 21;Index yang mungkin relevan:
CREATE INDEX idx_posts_status_created_id
ON posts (status, created_at DESC, id DESC);Urutannya penting. Jika filter status selalu dipakai, menaruhnya di depan sering membantu. Namun keputusan final tetap harus divalidasi dengan EXPLAIN.
Perbandingan Singkat: Offset vs Cursor
Offset pagination
- Mudah dipahami dan diimplementasikan.
- Cocok untuk halaman kecil atau kebutuhan lompat ke nomor halaman tertentu.
- Semakin lambat pada offset besar.
- Lebih rentan terhadap hasil tidak konsisten saat data baru masuk atau data terhapus.
Cursor pagination
- Lebih stabil untuk dataset besar.
- Biaya query cenderung konsisten untuk page berikutnya.
- Lebih cocok untuk infinite scroll, admin list besar, feed, dan API list.
- Tidak ideal untuk lompat bebas ke nomor halaman tertentu.
Contoh Query SQL yang Aman dan Stabil
Berikut pola dasar untuk mengambil halaman pertama:
SELECT id, created_at, title
FROM posts
WHERE status = 'published'
ORDER BY created_at DESC, id DESC
LIMIT 21;Dan untuk halaman berikutnya setelah cursor terakhir:
SELECT id, created_at, title
FROM posts
WHERE status = 'published'
AND (
created_at < $1
OR (created_at = $1 AND id < $2)
)
ORDER BY created_at DESC, id DESC
LIMIT 21;Beberapa catatan penting:
- Gunakan parameterized query, jangan merangkai SQL dari string mentah.
- Pastikan arah operator sesuai dengan arah sorting. Jika
DESC, maka halaman berikutnya biasanya memakai<. - Selalu pakai kolom tie-breaker unik seperti
id.
Format Cursor yang Aman untuk API
Cursor sebaiknya tidak dikirim sebagai dua parameter terpisah jika Anda ingin API lebih rapi. Umumnya cursor dikemas sebagai JSON lalu di-encode ke base64.
type CursorPayload = {
createdAt: string;
id: number;
};Contoh nilai sebelum di-encode:
{"createdAt":"2024-01-10T12:00:00.000Z","id":98765}Keuntungan format ini:
- mudah diperluas jika suatu saat ada kolom tambahan,
- client tidak perlu tahu detail implementasi query,
- lebih aman dibanding mengirim SQL-like input mentah.
Perlu dicatat, base64 bukan enkripsi. Fungsinya lebih ke transport format, bukan proteksi data sensitif.
Integrasi di Next.js App Router dengan Route Handler
Di App Router, endpoint API biasanya dibuat melalui route handler seperti app/api/posts/route.ts. Contoh berikut fokus pada alur cursor, bukan pada library database tertentu.
import { NextRequest, NextResponse } from 'next/server';
type CursorPayload = {
createdAt: string;
id: number;
};
function encodeCursor(payload: CursorPayload) {
return Buffer.from(JSON.stringify(payload)).toString('base64');
}
function decodeCursor(value: string): CursorPayload | null {
try {
const json = Buffer.from(value, 'base64').toString('utf8');
const parsed = JSON.parse(json);
if (!parsed.createdAt || typeof parsed.id !== 'number') {
return null;
}
return parsed;
} catch {
return null;
}
}
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const limitParam = Number(searchParams.get('limit') ?? '20');
const cursorParam = searchParams.get('cursor');
const limit = Math.min(Math.max(limitParam, 1), 100);
const pageSize = limit + 1;
let rows;
if (!cursorParam) {
rows = await db.query(
`SELECT id, created_at, title
FROM posts
WHERE status = $1
ORDER BY created_at DESC, id DESC
LIMIT $2`,
['published', pageSize]
);
} else {
const cursor = decodeCursor(cursorParam);
if (!cursor) {
return NextResponse.json(
{ error: 'Cursor tidak valid' },
{ status: 400 }
);
}
rows = await db.query(
`SELECT id, created_at, title
FROM posts
WHERE status = $1
AND (
created_at < $2
OR (created_at = $2 AND id < $3)
)
ORDER BY created_at DESC, id DESC
LIMIT $4`,
['published', cursor.createdAt, cursor.id, pageSize]
);
}
const items = rows.slice(0, limit);
const hasNextPage = rows.length > limit;
const lastItem = items[items.length - 1];
const nextCursor = hasNextPage && lastItem
? encodeCursor({
createdAt: new Date(lastItem.created_at).toISOString(),
id: lastItem.id,
})
: null;
return NextResponse.json({
data: items,
pageInfo: {
hasNextPage,
nextCursor,
},
});
}Hal-hal penting dari contoh di atas:
limitdibatasi agar client tidak meminta data berlebihan.- Cursor divalidasi sebelum dipakai ke query.
- Query memakai parameter, bukan string interpolation.
LIMIT pageSize + 1dipakai untuk mendeteksihasNextPage.
Format Response API yang Disarankan
Format response sebaiknya cukup jelas untuk konsumsi frontend:
{
"data": [
{
"id": 101,
"created_at": "2024-01-10T12:00:00.000Z",
"title": "Contoh"
}
],
"pageInfo": {
"hasNextPage": true,
"nextCursor": "eyJjcmVhdGVkQXQiOiIyMDI0LTAxLTEwVDEyOjAwOjAwLjAwMFoiLCJpZCI6MTAxfQ=="
}
}Jika perlu dukungan halaman sebelumnya, Anda dapat menambahkan:
prevCursor,hasPreviousPage.
Namun implementasi previous page perlu perhatian lebih agar arah query tetap konsisten.
Menangani Next dan Prev Cursor
Next cursor
Untuk urutan ORDER BY created_at DESC, id DESC, nextCursor biasanya memakai item terakhir dari halaman saat ini. Query berikutnya mencari data dengan nilai yang “lebih kecil” dari cursor tersebut.
Prev cursor
Mendukung halaman sebelumnya lebih rumit. Salah satu cara adalah menjalankan query dengan arah berlawanan:
SELECT id, created_at, title
FROM posts
WHERE status = 'published'
AND (
created_at > $1
OR (created_at = $1 AND id > $2)
)
ORDER BY created_at ASC, id ASC
LIMIT 21;Setelah hasil didapat, balik lagi urutannya di aplikasi agar tetap tampil DESC. Ini penting karena “sebelumnya” dalam daftar descending berarti mencari baris yang berada di atas cursor saat ini.
Jika kebutuhan utama hanya infinite scroll ke bawah, lebih sederhana untuk hanya menyediakan nextCursor.
Edge Case yang Sering Terjadi
Data baru masuk saat user sedang paging
Pada offset pagination, data baru di atas list bisa menggeser isi halaman berikutnya sehingga terjadi duplikasi atau item terlewat. Cursor pagination lebih tahan terhadap masalah ini karena navigasi didasarkan pada item terakhir yang benar-benar sudah dilihat.
Meski begitu, data baru yang masuk sebelum cursor memang tidak otomatis muncul di halaman berikutnya. Itu perilaku yang normal. Jika UI perlu menampilkan data terbaru, biasanya ada tombol refresh atau muat ulang dari awal.
Data terhapus di tengah navigasi
Jika item yang sebelumnya menjadi acuan cursor terhapus, umumnya halaman berikutnya masih bisa berjalan selama cursor menyimpan nilai kolom sort, bukan bergantung pada keberadaan record itu sendiri. Inilah alasan mengapa cursor sebaiknya menyimpan created_at dan id, bukan hanya nomor halaman.
Nilai sort berubah
Jika Anda memakai updated_at dan baris sering di-update, posisi item dapat berubah antar request. Ini bukan bug query, tetapi konsekuensi dari kolom sort yang dinamis. Untuk list yang perlu konsistensi tinggi selama sesi paging, created_at + id biasanya lebih stabil.
Kesalahan Umum Saat Migrasi dari Offset ke Cursor
- Sort tidak unik: hanya memakai
created_attanpaid. - Index tidak sesuai: query sudah diganti, tetapi index masih mengikuti pola lama.
- Salah operator: sorting
DESCtetapi filter cursor memakai>untuk next page. - Tidak membatasi limit: client bisa meminta ribuan baris sekaligus.
- Cursor tidak divalidasi: input rusak menyebabkan error parsing atau query aneh.
- Masih menghitung total page secara sinkron: ini sering menambah beban query lagi.
Jika UI memang butuh total item, hitung terpisah dan pahami bahwa query count pada tabel besar juga bisa mahal, terutama bila filter kompleks.
Checklist Debugging dengan EXPLAIN
Setelah migrasi ke cursor pagination, jangan langsung berasumsi query pasti cepat. Periksa rencana eksekusi dengan EXPLAIN atau EXPLAIN ANALYZE pada lingkungan yang aman.
Yang perlu dicek
- Apakah query memakai index scan atau masih jatuh ke sequential scan?
- Apakah urutan index cocok dengan
WHEREdanORDER BY? - Apakah ada sort mahal karena database tidak bisa memanfaatkan index untuk urutan?
- Apakah filter tambahan seperti
status,tenant_id, ataudeleted_atmembuat index lama tidak efektif? - Apakah jumlah baris yang discan jauh lebih besar daripada jumlah baris yang dikembalikan?
Contoh langkah praktis
- Jalankan query halaman pertama dengan
EXPLAIN. - Jalankan query halaman berikutnya dengan cursor realistis dari data produksi atau staging.
- Bandingkan apakah planner tetap memakai index yang sama.
- Jika masih lambat, cek apakah perlu index komposit baru sesuai filter aktual.
- Pastikan statistik database mutakhir jika planner tampak memilih rencana yang buruk.
Jika query masih lambat walau sudah memakai cursor, penyebabnya sering ada pada index yang tidak sesuai, filter tambahan yang tidak diakomodasi, atau urutan sort yang tidak stabil. Cursor bukan pengganti kebutuhan desain index yang benar.
Strategi Migrasi dari Endpoint Lama
Agar perubahan tidak memutus client lama, Anda bisa melakukan migrasi bertahap:
- Pertahankan endpoint lama berbasis
pagesementara waktu. - Tambahkan endpoint atau mode baru berbasis
cursor. - Ubah frontend admin/list untuk memakai
nextCursor. - Monitor query plan dan latensi endpoint baru.
- Setelah stabil, hapus pola offset pada list yang paling berat.
Ini membantu Anda memverifikasi bahwa bottleneck memang pindah dari offset scan ke index-backed cursor query.
Kapan Tetap Memakai Offset Pagination
Cursor pagination bukan jawaban untuk semua kasus. Tetap gunakan offset jika:
- dataset kecil dan tidak menimbulkan bottleneck,
- fitur utama adalah lompat ke halaman tertentu,
- urutan data sulit dibuat stabil,
- biaya kompleksitas implementasi tidak sebanding dengan manfaatnya.
Namun jika gejala yang Anda lihat adalah endpoint list makin lambat, beban database naik, dan halaman tinggi terasa berat, maka cursor pagination di Next.js App Router biasanya merupakan langkah yang lebih tepat daripada terus mengoptimalkan OFFSET.
Ringkasan Praktis
- Masalah utama offset: semakin tinggi page, semakin mahal query.
- Solusi: ganti ke cursor pagination berbasis kolom sort yang konsisten.
- Urutan aman: gunakan pasangan seperti
created_at DESC, id DESC. - Index penting: buat index komposit yang sesuai dengan filter dan urutan query.
- Implementasi Next.js: route handler menerima
cursor, menjalankan query terparameterisasi, lalu mengembalikannextCursor. - Validasi hasil: cek dengan
EXPLAIN, bukan asumsi.
Pada praktiknya, peningkatan terbesar dari cursor pagination bukan hanya karena API terlihat modern, tetapi karena query menjadi lebih selaras dengan cara database memanfaatkan index. Itu inti pengurangan query lambat saat data membesar.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!