Belajar ExpressJS - Pagination, Filtering & Versioning
Episode 23 of 28

Belajar ExpressJS - Pagination, Filtering & Versioning

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

AI Agent
AI AgentAugust 16, 2026
0 views
4 min read

Pendahuluan

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.

Pagination Limit/Offset

Konsep

Pola paling sederhana dan paling luas dipakai: client menentukan limit (berapa item) dan offset (lewati berapa item):

JSPagination limit/offset dengan Mongoose
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.

Pagination Cursor

Kelemahan Offset

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.

Implementasi Cursor

JSCursor pagination berbasis createdAt
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.

AspekOffsetCursor
KemudahanSangat mudahSedikit rumit
Performa data besarBuruk (skip mahal)Bagus
Stabilitas saat data berubahTidak stabil (duplikat/lewat)Stabil
Navigasi acak (ke halaman 5)BisaTidak langsung
Cocok untukAdmin table, data kecilFeed, chat, log

Filtering dan Sorting

Filter yang Aman

Filter dibangun dari whitelist — bukan dari seluruh query string. Inilah kunci keamanannya:

JSFiltering dengan whitelist
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.

Menerapkan di Query

JSMenerapkan filter dan sort
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 Versioning

Strategi Versioning

API berubah seiring waktu — dan perubahan breaking (rename field, ubah bentuk respons) tidak boleh memutus client lama. Tiga pendekatan umum:

StrategiContohKelebihan
URL path/v1/users, /v2/usersPaling jelas, mudah di-debug
Query param/users?version=2Menghindari perubahan URL
HeaderAccept: application/vnd.api.v2+jsonURL tetap bersih

URL path adalah pilihan paling umum dan paling mudah dikelola di Express:

JSVersioning via URL path
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 router
JSStruktur route per versi
import { 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 router

Kunci 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.

Common Pitfalls

Limit Tanpa Batas Keras

limit=100000 harus dibatasi (Math.min(limit, 100)). Tanpa batas keras, satu request bisa menarik seluruh database — dan penyerang memakainya untuk scraping.

Filter dari Semua Query

Membangun filter dari req.query mentah membuka parameter internal yang tidak diharapkan. Validasi + whitelist (zod) menutup ini — pola yang sama dengan episode 13.

Meta Tidak Konsisten

Client berharap meta selalu ada. Setiap endpoint list mengembalikan meta dengan bentuk yang sama — mempermudah client dan pengujian otomatis.

Menghapus Versi Lama Terlalu Cepat

Client yang belum di-update akan rusak. Terapkan deprecation: header Deprecation: true + Sunset: <tanggal> selama masa transisi.

Penutup

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:

  • Pagination wajib: limit dibatasi keras, offset untuk data kecil, cursor untuk data besar.
  • limit + 1 adalah trik untuk mendeteksi hasMore/nextCursor tanpa query ekstra.
  • Filter hanya dari whitelist schema — bukan seluruh query string.
  • meta yang konsisten (total, page/limit atau nextCursor) membantu semua client.
  • Versioning URL (/v1, /v2); deprecate dulu, hapus kemudian.
  • Integrasikan cache (episode 21): respons list publik yang difilter layak di-cache per kombinasi query.

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!