karawaci.kode

2026-08-28 · 10 min

API Versioning: URL Path vs Header vs Media Type — Pilih yang Tidak Menyesal Nanti

Pertanyaan ini datang di hampir setiap proyek baru yang saya bantu setup: “Kita pakai versioning yang mana — path, header, atau yang pakai Accept header itu?” Dan biasanya pertanyaan ini muncul pas REST API sudah mau launch, bukan di awal desain. Jawaban saya hampir selalu sama: URL path dulu, sisanya pertimbangkan setelah ada alasan konkret.

Kenapa Ini Penting Lebih dari yang Kelihatan

API versioning adalah keputusan yang hidup lama. Setelah ada client — mobile app, integrasi partner, atau bahkan frontend sendiri — yang bergantung pada satu pola versioning, mengubahnya mahal. Saya pernah pegang proyek fintech di Tangerang yang API-nya tidak punya versioning sama sekali. Setiap perubahan breaking harus dikomunikasikan manual ke delapan tim integrasi berbeda, dan dua kali dalam setahun ada downtime karena satu tim tidak update tepat waktu. Versioning bukan fitur mewah — itu kontrak dengan consumer API Anda.

Tiga strategi yang akan kita bedah:

  1. URL path versioning/v1/users, /v2/orders
  2. Custom header versioningApi-Version: 2 di request header
  3. Media type versioning (content negotiation)Accept: application/vnd.myapi.v2+json

URL Path Versioning

Cara paling umum dan paling pragmatis. Versi jadi bagian dari URL itu sendiri.

GET /v1/payments
GET /v2/payments

Di Express, implementasinya straightforward dengan router terpisah per versi:

// src/routes/index.ts
import { Router } from 'express';
import v1Router from './v1';
import v2Router from './v2';

const router = Router();

router.use('/v1', v1Router);
router.use('/v2', v2Router);

export default router;
// src/routes/v1/payments.ts
import { Router } from 'express';
const router = Router();

router.get('/', async (req, res) => {
  // Response shape v1 — amount sebagai string
  const payments = await getPayments();
  res.json(payments.map(p => ({
    id: p.id,
    amount: p.amount.toString(), // v1: string
    currency: p.currency,
  })));
});

export default router;
// src/routes/v2/payments.ts
import { Router } from 'express';
const router = Router();

router.get('/', async (req, res) => {
  // Response shape v2 — amount sebagai number, tambah fee breakdown
  const payments = await getPayments();
  res.json(payments.map(p => ({
    id: p.id,
    amount: p.amount,        // v2: number
    fee: p.fee,              // v2: field baru
    currency: p.currency,
    settled_at: p.settledAt, // v2: snake_case
  })));
});

export default router;

Keunggulan URL path versioning yang tidak sering disebut:

  • Cache CDN bekerja otomatis — Cloudflare, Fastly, dan NGINX cache berdasarkan URL. /v1/products dan /v2/products di-cache terpisah tanpa konfigurasi tambahan.
  • Log langsung informatif — baris NGINX GET /v2/payments 200 langsung bilang client pakai versi berapa, tanpa harus parse header.
  • Curl dan browser bisa langsung — tidak perlu set header khusus saat debugging atau demo ke stakeholder.
  • Dokumentasi Swagger/OpenAPI per versi — cukup mount dua spec di /v1/docs dan /v2/docs, tidak ada ambiguitas.

Kekurangannya: URL tidak lagi “murni” sebagai identifier resource. Secara purisme REST, /v1/users/123 dan /v2/users/123 adalah URL berbeda tapi menunjuk resource yang sama. Kalau Anda ketat soal REST philosophy, ini menggangu. Di production, saya tidak terlalu peduli soal ini.

Custom Header Versioning

Versi dikirim lewat HTTP header, bukan URL. URL tetap bersih.

GET /payments
Api-Version: 2

Implementasi di Express dengan middleware resolving versi:

// src/middleware/apiVersion.ts
import { Request, Response, NextFunction } from 'express';

declare global {
  namespace Express {
    interface Request {
      apiVersion: number;
    }
  }
}

const SUPPORTED_VERSIONS = [1, 2];
const DEFAULT_VERSION = 1;

export function resolveApiVersion(req: Request, res: Response, next: NextFunction) {
  const headerValue = req.headers['api-version'];
  const requested = headerValue ? parseInt(String(headerValue), 10) : DEFAULT_VERSION;

  if (!SUPPORTED_VERSIONS.includes(requested)) {
    return res.status(400).json({
      error: `Versi API ${requested} tidak didukung. Versi tersedia: ${SUPPORTED_VERSIONS.join(', ')}`,
    });
  }

  req.apiVersion = requested;

  // Tandai versi deprecated di response header
  if (requested === 1) {
    res.setHeader('Deprecation', 'true');
    res.setHeader('Sunset', 'Sat, 28 Feb 2027 23:59:59 GMT');
  }

  next();
}
// src/routes/payments.ts — satu file, bercabang per versi
import { Router } from 'express';
const router = Router();

router.get('/', async (req, res) => {
  const payments = await getPayments();

  if (req.apiVersion === 2) {
    return res.json(payments.map(p => ({
      id: p.id,
      amount: p.amount,
      fee: p.fee,
      currency: p.currency,
    })));
  }

  // Default v1
  return res.json(payments.map(p => ({
    id: p.id,
    amount: p.amount.toString(),
    currency: p.currency,
  })));
});

export default router;

Pola ini rapi untuk API yang URL-nya memang mau stabil — misalnya kalau URL-nya sudah di-embed di dokumentasi publik dan Anda tidak mau URL berubah. Tapi ada harga yang dibayar: setiap client yang bergantung pada header ini perlu konfigurasi tambahan. Browser fetch, curl default, dan webhook dari pihak ketiga semuanya perlu diingatkan untuk set header. Satu hal yang sering dilupa: load testing tools seperti k6 juga perlu dikonfigurasi manual untuk set header ini.

Media Type Versioning (Content Negotiation)

Versi dikodekan di Accept header mengikuti spesifikasi HTTP content negotiation.

GET /payments
Accept: application/vnd.myapi.v2+json

Di sisi server:

// src/middleware/contentNegotiation.ts
export function parseAcceptVersion(req: Request, res: Response, next: NextFunction) {
  const accept = req.headers['accept'] || '';

  // Cocokkan pola: application/vnd.myapi.v{N}+json
  const match = accept.match(/application\/vnd\.myapi\.v(\d+)\+json/);

  if (match) {
    req.apiVersion = parseInt(match[1], 10);
  } else if (accept.includes('application/json') || accept === '*/*' || !accept) {
    req.apiVersion = 1; // default ke v1 untuk client tanpa header
  } else {
    return res.status(406).json({
      error: 'Media type tidak didukung. Gunakan application/vnd.myapi.v2+json',
    });
  }

  next();
}

Ini yang paling “correct” secara HTTP semantics — Anda benar-benar menggunakan mekanisme content negotiation seperti yang dimaksud RFC. GitHub menggunakan pola ini di API v3 mereka. Tapi di production, saya jarang rekomendasikan ini untuk tim kecil karena:

  • Middleware parsing regex rawan edge case — ada client yang kirim Accept: application/json, application/vnd.myapi.v2+json;q=0.9 dan Anda harus parsing q-value dengan benar
  • CDN cache nyaris tidak bekerja tanpa konfigurasi Vary header yang tepat
  • Debugging lebih susah — lihat log NGINX, semua terlihat GET /payments 200, tidak langsung jelas versi mana yang dipanggil
  • Dokumentasi lebih kompleks untuk dijelaskan ke client baru

Trade-off Jujur

AspekURL PathCustom HeaderMedia Type
Kemudahan debugPaling mudahButuh curl -HButuh parsing Accept
CDN cacheOtomatisPerlu Vary configPerlu Vary config
REST purityRendahSedangTinggi
Kompleksitas clientRendahSedangTinggi
Log langsung informatifYaTidakTidak
Cocok untuk publik APICukupBaikBaik

Satu concern yang sering muncul: “URL path versioning berarti ada duplikasi kode antara v1 dan v2.” Ini concern yang valid tapi solusinya bukan memilih strategi versioning lain — solusinya adalah shared handler di layer service, dan router per versi hanya menangani transformasi response. Service layer tidak punya versioning.

// src/services/paymentService.ts — tidak ada versioning di sini
export async function listPayments(filters: PaymentFilters): Promise<Payment[]> {
  return db.payment.findMany({ where: filters });
}

// v1 router: transform ke shape lama
// v2 router: transform ke shape baru
// Bisnis logic di service — tetap satu

Menandai Versi Deprecated dengan Benar

Apapun strategi yang dipilih, tandai versi deprecated sesuai RFC 8594 sehingga client punya sinyal untuk upgrade:

// src/middleware/deprecation.ts
export function markDeprecatedVersions(req: Request, res: Response, next: NextFunction) {
  const deprecatedVersions: Record<number, string> = {
    1: 'Sat, 28 Feb 2027 23:59:59 GMT',
  };

  const sunset = deprecatedVersions[req.apiVersion];
  if (sunset) {
    res.setHeader('Deprecation', 'true');
    res.setHeader('Sunset', sunset);
    res.setHeader(
      'Link',
      '<https://api.yourapp.com/docs/migration/v2>; rel="deprecation"'
    );
  }

  next();
}

Header Sunset adalah tanggal ketika versi akan benar-benar dimatikan. Beberapa API client library modern seperti axios-cache-interceptor dan ky sudah bisa membaca header ini dan log warning otomatis. Di production kami, saya juga set alert Grafana yang notif kalau traffic ke versi deprecated masih di atas threshold seminggu sebelum sunset date.

Verdict

Untuk 95% project backend yang saya tangani — SaaS B2B Jakarta, fintech regional, internal tool enterprise — URL path versioning adalah pilihan yang tidak akan disesali. Mudah di-debug, cache bekerja tanpa konfigurasi tambahan, log langsung informatif, dan tidak ada surprise buat client baru yang lupa baca dokumentasi.

Pertimbangkan custom header versioning kalau dua kondisi ini terpenuhi: (1) API Anda publik dengan banyak integrasi eksternal dan URL canonical sudah terlanjur di-bookmark atau di-hardcode di dokumentasi partner, (2) tim Anda punya kapasitas untuk maintain dokumentasi yang lebih detail soal header requirement.

Media type versioning saya rekomendasikan hanya kalau Anda memang membangun platform yang serius soal REST semantics dan developer experience-nya setara GitHub atau Stripe — artinya ada tim khusus yang maintain SDK, changelog, dan developer portal. Untuk tim 3-5 orang yang belum ada alasan spesifik, ini over-engineering.

Yang lebih penting dari strategi mana yang dipilih: mulai versioning dari hari pertama, bahkan kalau belum ada v2. /v1/ di URL tidak menyakiti siapapun, tapi tidak punya versioning saat perlu breaking change menyakiti semua orang. Pelajaran yang saya dapat dari satu proyek Tangerang yang harus migrasi API tanpa versioning dengan 12 client aktif: tidak ada yang lebih mahal dari versioning yang dilakukan terlambat.

Ditulis oleh Reza Pradipta