Pagination berbasis OFFSET dan LIMIT adalah solusi standar untuk navigasi data. Namun, metode ini mengalami penurunan performa drastis ketika volume data membesar atau halaman yang diakses semakin dalam. Masalah ini bukan disebabkan oleh framework Next.js, melainkan oleh mekanisme penyimpanan dan pemindaian data di level engine database.
Akar Masalah OFFSET: O(N) B-Tree Traversal Scan
Saat mengeksekusi query seperti SELECT * FROM posts ORDER BY id LIMIT 20 OFFSET 500000, database tidak langsung melompat ke baris ke-500.001. Database mesin relasional (seperti PostgreSQL atau MySQL) harus membaca index tree, menelusuri 500.020 baris data, lalu membuang 500.000 baris pertama di memori sebelum mengembalikan 20 baris yang diminta.
Karakteristik komputasi ini menghasilkan kompleksitas waktu O(N) terhadap posisi offset. Dampaknya berupa beban disk I/O tinggi, cache buffer database terkuras, dan Response Time API melonjak.
Analisis Query Plan: OFFSET vs Keyset
Berikut perbandingan rencana eksekusi pada tabel dengan 1.000.000 data menggunakan PostgreSQL:
-- Query OFFSET halaman dalam
EXPLAIN ANALYZE
SELECT id, title, created_at
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 500000;
-- HASIL EXPLAIN ANALYZE:
-- Limit (cost=64120.30..64122.87 rows=20 width=64) (actual time=384.112..384.125 rows=20 loops=1)
-- -> Index Scan using idx_posts_created_at_id on posts (cost=0.43..128240.60 rows=1000000 width=64) (actual time=0.052..349.810 rows=500020 loops=1)
-- Planning Time: 0.120 ms
-- Execution Time: 384.180 msDatabase membutuhkan waktu ~384 ms untuk memindai 500.020 entri index. Bandingkan dengan keyset pagination:
-- Query Keyset menggunakan tuple filter
EXPLAIN ANALYZE
SELECT id, title, created_at
FROM posts
WHERE (created_at, id) < ('2026-03-01 10:00:00.000', '018dc5a2-3f12-7000-85aa-9c1234567890')
ORDER BY created_at DESC, id DESC
LIMIT 20;
-- HASIL EXPLAIN ANALYZE:
-- Limit (cost=0.43..2.98 rows=20 width=64) (actual time=0.035..0.048 rows=20 loops=1)
-- -> Index Scan using idx_posts_created_at_id on posts (cost=0.43..25648.12 rows=200000 width=64) (actual time=0.033..0.044 rows=20 loops=1)
-- Planning Time: 0.105 ms
-- Execution Time: 0.071 msKeyset pagination menghasilkan eksekusi 0.071 ms (ribuan kali lebih cepat) karena database langsung melompat ke titik referensi index melalui B-Tree binary search tanpa membaca baris data sebelumnya (O(log N) seek, O(1) fetch).
Fondasi: Composite Index dan Tuple Comparison
Keyset pagination membutuhkan penanda posisi (cursor) yang unik dan deterministik. Penggunaan satu kolom seperti created_at tidak cukup karena nilai duplikasi (timestamp identik) akan menyebabkan data terlewat atau berulang.
Solusinya adalah membuat composite index yang menggabungkan kolom sorting dengan kolom identitas unik:
CREATE INDEX idx_posts_created_at_id ON posts (created_at DESC, id DESC);Filter SQL memanfaatkan row value constructor atau pembandingan tuple: (created_at, id) < ($cursor_created_at, $cursor_id). Database memproses urutan prioritas: bandingkan created_at terlebih dahulu; jika bernilai sama, evaluasi tie-breaker pada kolom id.
Implementasi di Next.js App Router
Berikut adalah implementasi keyset pagination end-to-end pada Next.js Server Component. Cursor dikirimkan melalui URL searchParams dalam bentuk base64 URL-safe string.
1. Utility Cursor Encoder / Decoder
// lib/cursor.ts
interface CursorData {
createdAt: string;
id: string;
}
export function encodeCursor(data: CursorData): string {
return Buffer.from(JSON.stringify(data)).toString('base64url');
}
export function decodeCursor(cursorStr: string): CursorData | null {
try {
const decoded = Buffer.from(cursorStr, 'base64url').toString('utf8');
const parsed = JSON.parse(decoded);
if (typeof parsed.createdAt === 'string' && typeof parsed.id === 'string') {
return parsed;
}
return null;
} catch {
return null;
}
}2. Data Access Layer (SQL Engine)
// lib/posts.ts
import { sql } from '@/lib/db'; // Instance pg/pool/database client
import { decodeCursor, encodeCursor } from './cursor';
export interface Post {
id: string;
title: string;
created_at: Date;
}
export async function getPaginatedPosts(cursorParam?: string, limit = 20) {
const cursor = cursorParam ? decodeCursor(cursorParam) : null;
// ponytail: Gunakan limit + 1 untuk evaluasi hasNextPage tanpa menjalankan query COUNT terpisah.
const queryLimit = limit + 1;
const posts = cursor
? await sql<Post[]>`
SELECT id, title, created_at
FROM posts
WHERE (created_at, id) < (${cursor.createdAt}::timestamptz, ${cursor.id})
ORDER BY created_at DESC, id DESC
LIMIT ${queryLimit};
`
: await sql<Post[]>`
SELECT id, title, created_at
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT ${queryLimit};
`;
const hasMore = posts.length > limit;
const items = hasMore ? posts.slice(0, limit) : posts;
const lastItem = items[items.length - 1];
const nextCursor = hasMore && lastItem
? encodeCursor({
id: lastItem.id,
createdAt: lastItem.created_at.toISOString(),
})
: null;
return { items, nextCursor };
}3. Server Component Navigasi
// app/posts/page.tsx
import Link from 'next/link';
import { getPaginatedPosts } from '@/lib/posts';
interface PageProps {
searchParams: Promise<{ cursor?: string }>;
}
export default async function PostsPage({ searchParams }: PageProps) {
const resolvedParams = await searchParams;
const { items, nextCursor } = await getPaginatedPosts(resolvedParams.cursor, 20);
return (
<main className="max-w-2xl mx-auto p-6 space-y-4">
<h1 className="text-xl font-bold">Daftar Postingan</h1>
<ul className="divide-y divide-gray-200">
{items.map((post) => (
<li key={post.id} className="py-3">
<p className="font-medium">{post.title}</p>
<time className="text-sm text-gray-500">
{new Date(post.created_at).toLocaleString('id-ID')}
</time>
</li>
))}
</ul>
<div className="flex justify-between items-center pt-4">
{/* Navigasi awal jika user berada di halaman dalam */}
{resolvedParams.cursor ? (
<Link
href="/posts"
className="px-4 py-2 text-sm bg-gray-200 rounded hover:bg-gray-300"
>
← Halaman Pertama
</Link>
) : <span />}
{nextCursor ? (
<Link
href={`/posts?cursor=${nextCursor}`}
className="px-4 py-2 text-sm bg-blue-600 text-white rounded hover:bg-blue-700"
>
Berikutnya →
</Link>
) : (
<span className="text-sm text-gray-400">Akhir Data</span>
)}
</div>
</main>
);
}Trade-offs dan Pertimbangan Teknis
- Tidak Bisa Lompat ke Halaman Acak (Arbitrary Page Jump): Keyset pagination tidak mendukung tautan langsung ke "Halaman 47" karena sistem tidak menghitung total baris dan membutuhkan penanda baris terakhir dari halaman sebelumnya. Gunakan UI infinite scroll atau tombol Next/Prev.
- Navigasi Dua Arah (Previous Page): Membutuhkan pembalikan arah operator (
>) dan pengurutan index (ASC), lalu hasilnya di-reverse kembali di memori aplikasi. Jika histori halaman sebelumnya tidak mutlak harus stateless, simpan riwayat cursor di URL stack atau client-side state. - Konsistensi Data: Keyset pagination kebal terhadap masalah duplikasi atau data yang hilang ketika ada record baru disisipkan secara concurrent selama navigasi berlangsung.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!