Membangun endpoint list production-grade: pagination limit offset dan cursor, filtering dan sorting yang aman, respons ber-metadata, serta strategi API versioning.

Setelah di episode 4 kalian membangun route list pertama (yang mengembalikan seluruh data), episode 23 memperbaikinya menjadi endpoint list production-grade. Mengembalikan ribuan baris dalam satu respons adalah masalah performa dan UX: lambat, boros bandwidth, dan tidak berguna bagi client yang hanya butuh 20 item pertama.
Mengapa topik ini penting? Karena endpoint list adalah permukaan yang paling sering dipanggil setiap API. Pagination, filtering, sorting, dan versioning bukan fitur — mereka adalah harapan dasar konsumen API. Episode ini menyatukan pola validasi (episode 13) dan caching (episode 21) ke dalam satu endpoint yang utuh.
Pola paling sederhana dan paling luas dipakai: client menentukan limit (berapa item) dan offset (lewati berapa item):
export const listUsers = async (req, res) => {
const { limit = 10, offset = 0 } = req.query
const skip = Number(offset)
const take = Math.min(Number(limit), 100)
const [users, total] = await Promise.all([
User.find({}).sort({ createdAt: -1 }).skip(skip).limit(take).lean(),
User.countDocuments({}),
])
res.json({
data: users,
meta: {
limit: take,
offset: skip,
total,
hasMore: skip + users.length < total,
},
})
}Math.min(limit, 100) — batas keras agar satu request tidak menarik ribuan data.countDocuments berjalan paralel dengan query (Promise.all) — tanpa menambah latensi berurutan.meta memberi client informasi navigasi tanpa menebak.Tip
Selalu sertakan meta di respons list (limit, offset, total, hasMore). Client tidak perlu menebak kapan berhenti scroll — ini juga menghindari satu halaman terakhir yang kosong membingungkan.
Pada data besar, offset mahal: untuk halaman 100.000, database tetap harus melewati 100.000 baris. Cursor pagination memakai penanda dari data itu sendiri — jauh lebih cepat dan stabil saat data berubah di antara dua halaman.
export const listMessages = async (req, res) => {
const limit = Math.min(Number(req.query.limit || 20), 100)
const cursor = req.query.cursor
? new Date(req.query.cursor)
: new Date()
const messages = await Message.find({
createdAt: { $lt: cursor },
})
.sort({ createdAt: -1 })
.limit(limit + 1)
.lean()
const hasMore = messages.length > limit
const items = hasMore ? messages.slice(0, limit) : messages
const nextCursor = hasMore
? items[items.length - 1].createdAt.toISOString()
: null
res.json({
data: items,
meta: { limit, nextCursor },
})
}Trik limit + 1 adalah jantung cursor pagination: tarik satu baris ekstra hanya untuk tahu apakah masih ada halaman berikutnya. nextCursor memakai nilai dari data terakhir — bukan angka halaman — sehingga stabil walau data ditambah/hapus di antara halaman.
| Aspek | Offset | Cursor |
|---|---|---|
| Kemudahan | Sangat mudah | Sedikit rumit |
| Performa data besar | Buruk (skip mahal) | Bagus |
| Stabilitas saat data berubah | Tidak stabil (duplikat/lewat) | Stabil |
| Navigasi acak (ke halaman 5) | Bisa | Tidak langsung |
| Cocok untuk | Admin table, data kecil | Feed, chat, log |
Filter dibangun dari whitelist — bukan dari seluruh query string. Inilah kunci keamanannya:
import { z } from "zod"
export const listUsersQuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(10),
role: z.enum(["admin", "editor", "viewer"]).optional(),
active: z.enum(["true", "false"]).optional(),
sort: z.enum(["createdAt", "name", "email"]).default("createdAt"),
order: z.enum(["asc", "desc"]).default("desc"),
})Validasi zod (episode 13) membuang semua parameter yang tidak dikenal — client yang mengirim ?secret=1 tidak mendapatkan apa pun. Nilai yang lolos sudah dibatasi enumerasi.
export const listUsers = async (req, res) => {
const { page, limit, role, active, sort, order } = req.query
const filter = {}
if (role) filter.role = role
if (active) filter.active = active === "true"
const skip = (page - 1) * limit
const sortDir = order === "desc" ? -1 : 1
const [users, total] = await Promise.all([
User.find(filter).sort({ [sort]: sortDir }).skip(skip).limit(limit).lean(),
User.countDocuments(filter),
])
res.json({ data: users, meta: { page, limit, total } })
}sort hanya bisa memakai field dari whitelist schema — client tidak bisa menyortir kolom internal. Filter juga memakai field yang sama untuk countDocuments dan query, jadi meta.total selalu akurat terhadap filter aktif.
API berubah seiring waktu — dan perubahan breaking (rename field, ubah bentuk respons) tidak boleh memutus client lama. Tiga pendekatan umum:
| Strategi | Contoh | Kelebihan |
|---|---|---|
| URL path | /v1/users, /v2/users | Paling jelas, mudah di-debug |
| Query param | /users?version=2 | Menghindari perubahan URL |
| Header | Accept: application/vnd.api.v2+json | URL tetap bersih |
URL path adalah pilihan paling umum dan paling mudah dikelola di Express:
import { Router } from "express"
import v1 from "./routes/v1.js"
import v2 from "./routes/v2.js"
const router = Router()
router.use("/v1", v1)
router.use("/v2", v2)
export default routerimport { Router } from "express"
import { listUsers } from "../controllers/v2/userController.js"
const router = Router()
router.get("/users", listUsers)
// v2: respons users sekarang menyertakan field profile
export default routerKunci versioning: versi lama tidak dihapus mendadak. Deprecate (header Deprecation + Sunset), beri masa transisi, lalu hapus saat traffic lama habis.
Note
Versioning bukan alasan untuk melipatgandakan kode. Bagikan logika yang sama lewat controller berlapis: v2 memanggil helper yang sama dengan transformasi respons berbeda. Tujuan versioning adalah kompatibilitas, bukan duplikasi.
limit=100000 harus dibatasi (Math.min(limit, 100)). Tanpa batas keras, satu request bisa menarik seluruh database — dan penyerang memakainya untuk scraping.
Membangun filter dari req.query mentah membuka parameter internal yang tidak diharapkan. Validasi + whitelist (zod) menutup ini — pola yang sama dengan episode 13.
Client berharap meta selalu ada. Setiap endpoint list mengembalikan meta dengan bentuk yang sama — mempermudah client dan pengujian otomatis.
Client yang belum di-update akan rusak. Terapkan deprecation: header Deprecation: true + Sunset: <tanggal> selama masa transisi.
Episode 23 menyempurnakan endpoint list: pagination limit/offset dengan meta, cursor pagination untuk data besar, filtering dan sorting yang di-whitelist lewat zod, serta strategi versioning URL yang aman.
Inti yang harus dibawa pulang:
limit dibatasi keras, offset untuk data kecil, cursor untuk data besar.limit + 1 adalah trik untuk mendeteksi hasMore/nextCursor tanpa query ekstra.meta yang konsisten (total, page/limit atau nextCursor) membantu semua client./v1, /v2); deprecate dulu, hapus kemudian.Di episode 24 selanjutnya kita akan membawa aplikasi ke produksi: deployment (PM2, Docker, Cloud) — PM2 cluster, Dockerfile multi-stage, reverse proxy nginx, dan platform cloud. Sampai jumpa di episode 24!