Next.js infinite scroll tanpa OFFSET berat dengan cursor SQL adalah pendekatan yang lebih cocok untuk feed atau list yang terus membesar. Masalah utamanya sederhana: LIMIT/OFFSET terlihat mudah di awal, tetapi semakin dalam halaman yang diakses, database harus melewati semakin banyak baris sebelum mengembalikan hasil.
Untuk list produk, aktivitas, komentar, atau feed timeline, solusi yang lebih stabil biasanya adalah cursor-based pagination. Dengan cursor, query tidak meminta database “lompat ke baris ke-10000”, tetapi “ambil data setelah item terakhir yang sudah saya lihat”. Hasilnya lebih efisien, lebih konsisten untuk infinite scroll, dan lebih cocok untuk data yang terus bertambah.
Mengapa LIMIT/OFFSET makin lambat saat data membesar
Pola klasik pagination SQL biasanya seperti ini:
SELECT id, title, created_at
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 10000;Secara logika memang benar. Namun secara performa, database tetap perlu memproses baris sebelum offset tersebut. Walaupun ada index yang membantu proses sorting, offset besar tetap membuat pekerjaan tambahan karena mesin database perlu melewati banyak entri untuk sampai ke titik awal hasil.
Untuk infinite scroll, pola ini punya beberapa masalah nyata:
- Semakin dalam scroll, query makin berat.
- Data bisa bergeser jika ada insert baru di atas, sehingga pengguna bisa melihat item yang lompat atau terduplikasi.
- Pengalaman feed kurang stabil pada dataset yang aktif berubah.
OFFSET masih berguna pada banyak kasus, tetapi untuk feed besar dan sering diakses, itu sering bukan pilihan terbaik.
Konsep cursor-based pagination
Pada cursor-based pagination, API tidak menerima page atau offset sebagai penentu posisi utama. Sebagai gantinya, API menerima cursor yang mewakili posisi item terakhir dari halaman sebelumnya.
Contoh alurnya:
- Client meminta 20 item pertama tanpa cursor.
- Server mengembalikan 20 item plus
nextCursor. - Client memakai
nextCursoruntuk meminta batch berikutnya.
Jika urutan data adalah terbaru ke terlama, maka cursor sering berisi nilai kolom pengurutan terakhir, misalnya kombinasi created_at dan id.
Kenapa kombinasi dua kolom? Karena created_at saja sering tidak unik. Jika banyak row memiliki timestamp sama, urutan bisa ambigu. Infinite scroll butuh ordering yang stabil dan deterministik.
Syarat penting: ordering harus stabil
Cursor hanya aman jika urutan hasil benar-benar konsisten. Kesalahan yang paling sering adalah melakukan pagination pada kolom yang tidak unik tanpa tie-breaker.
Contoh yang kurang aman:
ORDER BY created_at DESCJika dua row punya created_at yang sama, hasil antar request bisa berubah. Ini bisa menyebabkan item hilang atau muncul dua kali.
Contoh yang lebih aman:
ORDER BY created_at DESC, id DESCDengan begitu, setiap baris punya posisi yang jelas. Maka cursor juga harus membawa kedua nilai tersebut.
Aturan praktis memilih kolom cursor
- Gunakan kolom yang ikut dipakai di
ORDER BY. - Pastikan urutan total deterministik, biasanya dengan menambahkan
idsebagai tie-breaker. - Hindari field yang mudah berubah jika field itu dipakai sebagai posisi cursor.
Jika Anda mengurutkan berdasarkan updated_at, misalnya, lalu record sering berubah, item bisa berpindah posisi di tengah pagination. Itu tidak selalu salah, tetapi perlu dipahami konsekuensinya.
Query SQL cursor-based yang umum dipakai
Misalkan kita ingin menampilkan feed post terbaru dengan urutan:
ORDER BY created_at DESC, id DESCMaka request pertama tanpa cursor bisa seperti ini:
SELECT id, title, created_at
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 21;Kenapa LIMIT 21 jika page size-nya 20? Supaya kita tahu apakah masih ada halaman berikutnya. Ambil satu item ekstra, lalu pakai itu untuk menentukan hasMore.
Request berikutnya, jika item terakhir dari batch sebelumnya adalah:
created_at = '2025-01-15 10:30:00'
id = 845Maka query selanjutnya:
SELECT id, title, created_at
FROM posts
WHERE (created_at < '2025-01-15 10:30:00')
OR (created_at = '2025-01-15 10:30:00' AND id < 845)
ORDER BY created_at DESC, id DESC
LIMIT 21;Logikanya: ambil baris yang posisinya setelah item terakhir pada urutan descending tersebut.
Jika database mendukung row value comparison
Pada beberapa database, Anda bisa menulis bentuk yang lebih ringkas:
SELECT id, title, created_at
FROM posts
WHERE (created_at, id) < ('2025-01-15 10:30:00', 845)
ORDER BY created_at DESC, id DESC
LIMIT 21;Namun untuk artikel praktis dan portabilitas, bentuk eksplisit dengan OR dan tie-breaker lebih aman dipahami.
Index yang relevan agar query tetap cepat
Cursor-based pagination baru terasa manfaatnya jika didukung index yang sesuai. Karena query melakukan filter dan sort berdasarkan created_at dan id, index komposit biasanya diperlukan.
Contoh:
CREATE INDEX idx_posts_created_id_desc
ON posts (created_at DESC, id DESC);Beberapa hal penting:
- Urutan kolom index harus mengikuti pola query.
- Kolom tie-breaker seperti
idpenting agar sort deterministik. - Jika ada filter tambahan tetap, misalnya hanya data publik, Anda mungkin butuh index yang juga mempertimbangkan kolom filter itu.
Contoh jika query selalu memfilter data yang sudah dipublikasikan:
SELECT id, title, created_at
FROM posts
WHERE status = 'published'
AND (
(created_at < ?)
OR (created_at = ? AND id < ?)
)
ORDER BY created_at DESC, id DESC
LIMIT 21;Dalam kasus seperti itu, index yang relevan bisa berubah menjadi sesuatu seperti kombinasi (status, created_at, id). Detail terbaik tergantung distribusi data dan pola query aktual, jadi cek execution plan di database Anda.
Jangan berhenti di “sudah pakai cursor”. Jika index tidak cocok, query tetap bisa lambat.
Desain response JSON cursor untuk API
Response API sebaiknya sederhana dan eksplisit. Bentuk umum:
{
"items": [
{
"id": 912,
"title": "Post A",
"created_at": "2025-01-15T10:32:10.000Z"
}
],
"pageInfo": {
"nextCursor": "eyJjcmVhdGVkQXQiOiIyMDI1LTAxLTE1VDEwOjMyOjEwLjAwMFoiLCJpZCI6OTEyfQ==",
"hasMore": true
}
}Cursor sering dienkode sebagai base64 JSON agar client tidak perlu memahami struktur internal query. Isinya bisa berupa:
{
"createdAt": "2025-01-15T10:32:10.000Z",
"id": 912
}Ini bukan mekanisme keamanan. Base64 hanya encoding, bukan enkripsi. Jika cursor membawa informasi sensitif, gunakan strategi lain seperti signature atau token server-side. Untuk kasus feed biasa, encoding sederhana sering cukup.
Implementasi Route Handler Next.js App Router
Di Next.js App Router, Anda bisa meletakkan endpoint di Route Handler, misalnya app/api/posts/route.ts. Contoh berikut fokus pada validasi parameter, decoding cursor, query SQL, dan response JSON. Koneksi database disederhanakan agar tetap fokus pada pola pagination.
import { NextRequest, NextResponse } from 'next/server'
type CursorPayload = {
createdAt: string
id: number
}
const DEFAULT_LIMIT = 20
const MAX_LIMIT = 50
function encodeCursor(payload: CursorPayload): string {
return Buffer.from(JSON.stringify(payload)).toString('base64')
}
function decodeCursor(cursor: string): CursorPayload | null {
try {
const json = Buffer.from(cursor, 'base64').toString('utf8')
const parsed = JSON.parse(json)
if (
!parsed ||
typeof parsed.createdAt !== 'string' ||
typeof parsed.id !== 'number'
) {
return null
}
return parsed
} catch {
return null
}
}
function parseLimit(value: string | null): number {
const n = Number(value)
if (!Number.isInteger(n) || n <= 0) return DEFAULT_LIMIT
return Math.min(n, MAX_LIMIT)
}
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url)
const limit = parseLimit(searchParams.get('limit'))
const cursorParam = searchParams.get('cursor')
let cursor: CursorPayload | null = null
if (cursorParam) {
cursor = decodeCursor(cursorParam)
if (!cursor) {
return NextResponse.json(
{ error: 'Invalid cursor' },
{ status: 400 }
)
}
}
const pageSizePlusOne = limit + 1
// Pseudocode database access.
// Ganti db.query(...) dengan driver SQL yang Anda pakai.
let rows
if (!cursor) {
rows = await db.query(
`
SELECT id, title, created_at
FROM posts
WHERE status = 'published'
ORDER BY created_at DESC, id DESC
LIMIT ?
`,
[pageSizePlusOne]
)
} else {
rows = await db.query(
`
SELECT id, title, created_at
FROM posts
WHERE status = 'published'
AND (
created_at < ?
OR (created_at = ? AND id < ?)
)
ORDER BY created_at DESC, id DESC
LIMIT ?
`,
[cursor.createdAt, cursor.createdAt, cursor.id, pageSizePlusOne]
)
}
const hasMore = rows.length > limit
const items = hasMore ? rows.slice(0, limit) : rows
const lastItem = items[items.length - 1]
const nextCursor = lastItem
? encodeCursor({
createdAt: new Date(lastItem.created_at).toISOString(),
id: lastItem.id,
})
: null
return NextResponse.json({
items,
pageInfo: {
nextCursor: hasMore ? nextCursor : null,
hasMore,
},
})
}Catatan implementasi
- Batasi limit maksimum. Jangan biarkan client meminta 1000 item per request tanpa kontrol.
- Validasi cursor. Cursor rusak atau dimanipulasi harus menghasilkan
400 Bad Request, bukan error internal. - Gunakan parameterized query. Hindari menyusun SQL dari string mentah.
- Ambil satu item ekstra. Ini pola paling praktis untuk menentukan
hasMore.
Menghubungkan ke infinite scroll di Next.js tanpa melebar ke frontend umum
Di sisi Next.js, komponen client cukup menyimpan daftar item dan nextCursor dari response terakhir. Saat pengguna mencapai bawah list, kirim request berikutnya ke endpoint yang sama dengan parameter cursor.
Contoh bentuk URL:
/api/posts?limit=20
/api/posts?limit=20&cursor=eyJjcmVhdGVkQXQiOiIyMDI1LTAxLTE1VDEwOjMyOjEwLjAwMFoiLCJpZCI6OTEyfQ==Yang penting di sini bukan teknik intersection observer-nya, melainkan kontrak API yang konsisten: client cukup tahu bahwa batch berikutnya diambil memakai nextCursor dari response sebelumnya.
Validasi parameter yang sebaiknya tidak diabaikan
Pada API pagination, validasi sering dianggap sepele padahal berpengaruh ke performa dan debugging.
Parameter yang perlu divalidasi
- limit: harus integer positif, dan dibatasi maksimum.
- cursor: harus bisa didecode dan memiliki struktur yang benar.
- filter lain: jika ada parameter seperti status, category, tenant, atau user scope, semua harus tervalidasi sebelum query dijalankan.
Kesalahan umum
- Menerima cursor tanpa memastikan tipe datanya benar.
- Mengizinkan limit terlalu besar sehingga endpoint dipakai sebagai bulk export.
- Mengubah filter di tengah pagination tanpa menyadari cursor lama jadi tidak relevan.
Jika filter ikut memengaruhi dataset, sebaiknya cursor diperlakukan hanya valid untuk kombinasi filter yang sama. Banyak tim memilih menganggap perubahan filter berarti memulai pagination dari awal.
Edge case yang sering muncul pada feed nyata
1. Data baru masuk di tengah pagination
Ini kasus yang paling umum. Misalnya pengguna sudah memuat 20 item pertama, lalu ada post baru masuk di paling atas sebelum ia meminta halaman berikutnya.
Dengan OFFSET, item bisa bergeser dan menyebabkan data terlewat atau duplikat. Dengan cursor, halaman berikutnya tetap diambil berdasarkan item terakhir yang sudah dilihat, sehingga aliran ke bawah lebih stabil.
Konsekuensinya: item baru yang masuk setelah request pertama tidak otomatis muncul di batch berikutnya jika posisinya berada di atas cursor saat ini. Itu biasanya justru perilaku yang diinginkan pada infinite scroll. Jika ingin menampilkan item baru, lakukan refresh atau tampilkan indikator “ada data baru”.
2. Duplikasi item di client
Walaupun cursor mengurangi masalah shifting, duplikasi tetap bisa terjadi, terutama jika:
- Ordering tidak stabil.
- Data yang dipakai untuk sort berubah.
- Client melakukan retry atau race condition pada beberapa request paralel.
Praktik aman di sisi client adalah melakukan dedup berdasarkan id saat menggabungkan item lama dan item baru. Ini bukan pengganti desain query yang benar, tetapi lapisan perlindungan tambahan.
3. Record terhapus di tengah pagination
Jika beberapa item terhapus setelah batch pertama dimuat, halaman berikutnya bisa berisi lebih sedikit item dari yang diharapkan, tetapi posisi cursor tetap valid selama ordering dan kondisi query konsisten.
4. Sorting memakai kolom yang berubah
Jika Anda mengurutkan berdasarkan updated_at dan ada update pada row lama, row itu bisa naik ke atas atau pindah posisi. Untuk feed yang menuntut stabilitas tinggi, lebih aman gunakan kolom yang tidak berubah seperti created_at ditambah id.
5. Backward pagination
Maju ke bawah adalah kasus paling umum, tetapi kadang Anda juga perlu tombol “previous” atau kemampuan scroll balik dengan posisi yang benar.
Pola umumnya:
- Simpan startCursor dan endCursor untuk setiap batch.
- Untuk backward pagination, balik operator pembanding dan urutan query, lalu normalisasi hasil sebelum dikirim ke client.
Contoh konsep jika Anda ingin mengambil item sebelum suatu posisi dalam urutan descending:
SELECT id, title, created_at
FROM posts
WHERE status = 'published'
AND (
created_at > ?
OR (created_at = ? AND id > ?)
)
ORDER BY created_at ASC, id ASC
LIMIT 21;Setelah hasil didapat, biasanya Anda balik lagi urutannya di aplikasi agar tetap tampil sebagai DESC di client.
Backward pagination menambah kompleksitas. Jika kebutuhan Anda hanya infinite scroll ke bawah, jangan implementasikan ini tanpa alasan jelas.
Trade-off cursor pagination yang perlu dipahami
Cursor pagination bukan solusi universal. Ia unggul pada list besar dan feed dinamis, tetapi ada trade-off:
- Tidak ideal untuk lompat ke halaman arbitrer seperti “langsung ke halaman 57”.
- Lebih kompleks daripada
pagedanoffset. - Butuh ordering yang disiplin dan desain index yang tepat.
- Cursor bergantung pada urutan dan filter, jadi perubahan query bisa membuat cursor lama tidak berlaku.
Jika kebutuhan UI memang tabel admin sederhana dengan total halaman yang jelas dan data tidak terlalu besar, OFFSET bisa tetap lebih praktis.
Tips debugging saat query cursor terasa tidak konsisten
- Cek ORDER BY. Pastikan ada tie-breaker unik, biasanya
id. - Cek isi cursor. Pastikan nilai yang diencode benar-benar berasal dari item terakhir yang dikirim.
- Cek execution plan database. Lihat apakah index yang dipakai sesuai harapan.
- Uji data dengan timestamp kembar. Banyak bug pagination baru muncul saat beberapa row punya
created_atyang sama. - Pastikan filter konsisten. Query pertama dan berikutnya harus memakai filter identik.
- Log request dan response pagination. Simpan
limit,cursor, jumlah row hasil, dan item boundary untuk investigasi duplikasi atau gap.
Kapan OFFSET masih layak, kapan harus pindah ke cursor
OFFSET masih layak dipakai jika
- Dataset kecil atau menengah dan tidak tumbuh terlalu cepat.
- Query jarang masuk ke halaman sangat dalam.
- Anda butuh nomor halaman yang eksplisit, misalnya dashboard admin atau hasil pencarian sederhana.
- Data relatif statis, sehingga shifting bukan masalah besar.
- Kesederhanaan implementasi lebih penting daripada optimasi feed jangka panjang.
Sebaiknya pindah ke cursor jika
- Anda membangun infinite scroll atau feed timeline.
- Jumlah data besar dan pengguna sering memuat halaman berikutnya berulang kali.
- Data terus bertambah atau berubah saat user sedang membaca.
- OFFSET besar mulai memperlambat query.
- Anda ingin hasil pagination lebih stabil terhadap insert baru.
Checklist implementasi
- Tentukan urutan yang stabil, misalnya
ORDER BY created_at DESC, id DESC. - Buat index komposit yang sesuai dengan pola filter dan sort.
- Gunakan cursor yang membawa semua kolom boundary yang dibutuhkan.
- Validasi
limitdancursordi Route Handler Next.js. - Ambil
limit + 1item untuk menentukanhasMore. - Dedup item di client berdasarkan id jika perlu.
- Uji edge case: timestamp sama, insert baru, delete, dan retry request.
Jika tujuan Anda adalah membangun Next.js infinite scroll tanpa OFFSET berat dengan cursor SQL, inti desainnya bukan sekadar mengganti parameter offset menjadi cursor. Yang paling penting adalah ordering stabil, query pembanding yang benar, dan index yang mendukung. Tiga hal itu yang membuat feed tetap cepat dan hasilnya konsisten saat data terus membesar.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!