14. API Integration: Advanced TypeScript Instance

Kita mengadopsi pola API Instance yang robust, namun ditingkatkan dengan TypeScript Strict Mode. Semua HTTP client wajib mengaktifkan withCredentials: true atau credentials: "include" agar cookie sesi (Better Auth) mengalir otomatis ke backend.

Standar Format Response & Error API

Untuk menjamin konsistensi integrasi di sisi client, backend dan frontend menyepakati struktur payload JSON standar untuk response sukses dan error:

1. Payload Sukses (2xx Success Response)

Setiap response sukses dibungkus dalam properti data, sehingga helper wrapper (seperti APIInstance.get) dapat langsung me-return bagian data tersebut untuk memangkas boilerplate .data.data di sisi React.

{
  "success": true,
  "message": "Data berhasil diambil",
  "data": {
    "id": "usr_9128",
    "name": "Jane Doe",
    "email": "jane@example.com",
    "role": "ADMIN"
  }
}

2. Payload Error (4xx / 5xx Error Response)

Response error yang dikirimkan server menyertakan properti code (untuk lokalisasi / penanganan error terprogram) serta objek errors untuk validasi field-level (misalnya form validation).

{
  "success": false,
  "message": "Validasi input gagal",
  "code": "VALIDATION_ERROR",
  "errors": {
    "email": [
      "Format email tidak valid"
    ],
    "password": [
      "Password minimal harus 8 karakter"
    ]
  }
}

Struktur payload error di atas nantinya akan dinormalisasi oleh HTTP Client ke dalam tipe data TypeScript ApiError:

export interface ApiError {
  message: string;
  status: number;
  code: string;
  errors: Record<string, string[]> | null;
}

Contoh services/api.ts — Axios Instance + APIInstance Wrapper:

import axios, {
  AxiosInstance,
  AxiosResponse,
  InternalAxiosRequestConfig,
  AxiosRequestConfig,
} from "axios";

export interface ApiError {
  message: string;
  status: number;
  code: string;
  errors: Record<string, string[]> | null;
}

interface CustomAxiosConfig extends InternalAxiosRequestConfig {
  skipAuth?: boolean;
}

const BASE_URL = process.env.NEXT_PUBLIC_API_URL || "https://api.my-app.com/v1";

const api: AxiosInstance = axios.create({
  baseURL: BASE_URL,
  timeout: 15000,
  withCredentials: true, // Cookie sesi Better Auth mengalir otomatis ✅
  headers: {
    Accept: "application/json",
    "Content-Type": "application/json",
  },
});

// Request Interceptor
api.interceptors.request.use(
  (config: CustomAxiosConfig) => {
    config.headers["X-Requested-With"] = "XMLHttpRequest";
    if (config.skipAuth) config.withCredentials = false;
    return config;
  },
  (error) => Promise.reject(error),
);

// Queue untuk menangani request beruntun saat refresh token sedang berjalan
let isRefreshing = false;
let failedQueue: any[] = [];

const processQueue = (error: any, token: string | null = null) => {
  failedQueue.forEach((prom) => {
    if (error) {
      prom.reject(error);
    } else {
      prom.resolve(token);
    }
  });
  failedQueue = [];
};

// Response Interceptor
api.interceptors.response.use(
  (response: AxiosResponse) => response,
  async (error) => {
    const originalRequest = error.config;

    // Jika response unauthorized (401) dan request belum ditandai retry
    if (error.response?.status === 401 && !originalRequest._retry) {
      if (isRefreshing) {
        // Jika proses refresh token sedang berjalan, masukkan request ke antrean
        return new Promise((resolve, reject) => {
          failedQueue.push({ resolve, reject });
        })
          .then(() => {
            return api(originalRequest);
          })
          .catch((err) => {
            return Promise.reject(err);
          });
      }

      originalRequest._retry = true;
      isRefreshing = true;

      try {
        // Panggil endpoint refresh token (cookie HttpOnly terkirim otomatis jika withCredentials aktif)
        await axios.post(
          `${BASE_URL}/auth/refresh`,
          {},
          { withCredentials: true }
        );

        isRefreshing = false;
        processQueue(null);

        // Ulangi request asli yang sempat tertunda
        return api(originalRequest);
      } catch (refreshError: any) {
        isRefreshing = false;
        processQueue(refreshError);

        // Jika refresh token juga expired/gagal, arahkan ke login
        if (typeof window !== "undefined") {
          const path = window.location.pathname + window.location.search;
          window.location.href = `/login?message=session_expired&callbackUrl=${encodeURIComponent(path)}`;
        }
        return Promise.reject(refreshError);
      }
    }

    const normalizedError: ApiError = {
      message: error.response?.data?.message || "Terjadi kesalahan pada server",
      status: error.response?.status || 500,
      code: error.response?.data?.code || "SERVER_ERROR",
      errors: error.response?.data?.errors || null,
    };
    return Promise.reject(normalizedError);
  },
);

// Wrapper terstandardisasi — konsumsi langsung tanpa .data
export const APIInstance = {
  // GET
  get<T>(path: string, config?: AxiosRequestConfig) {
    return api.get<T>(path, config).then((res) => res.data);
  },
  // POST JSON & Form
  postJson<T>(path: string, data: any, config?: AxiosRequestConfig) {
    return api.post<T>(path, data, config).then((res) => res.data);
  },
  postForm<T>(path: string, formData: FormData, config?: AxiosRequestConfig) {
    return api
      .post<T>(path, formData, {
        ...config,
        headers: { ...config?.headers, "Content-Type": "multipart/form-data" },
      })
      .then((res) => res.data);
  },
  // PUT JSON & Form
  putJson<T>(path: string, data: any, config?: AxiosRequestConfig) {
    return api.put<T>(path, data, config).then((res) => res.data);
  },
  putForm<T>(path: string, formData: FormData, config?: AxiosRequestConfig) {
    return api
      .put<T>(path, formData, {
        ...config,
        headers: { ...config?.headers, "Content-Type": "multipart/form-data" },
      })
      .then((res) => res.data);
  },
  // DELETE (bodyless)
  delete<T>(path: string, config?: AxiosRequestConfig) {
    return api.delete<T>(path, config).then((res) => res.data);
  },
};

export default APIInstance;

Keunggulan & Kekurangan Axios

Berikut adalah analisis keunggulan dan kekurangan penggunaan Axios sebagai HTTP Client default di dalam proyek:

🟢 Keunggulan (Advantages)

  • Automatic JSON Transformation: Secara otomatis menserialisasi payload JSON saat mengirim request dan mengurai (parse) data JSON secara otomatis saat menerima response.
  • Interceptors (Request & Response): Fitur interceptor yang sangat matang untuk memodifikasi request secara global (seperti menyuntikkan token) atau menangani error secara terpusat (seperti redirect ke halaman login atau melakukan silent refresh token pada status 401).
  • Penanganan Error yang Solid: Otomatis melempar error (reject Promise) untuk status code di luar rentang 2xx, dan menyediakan helper typeguards bawaan seperti axios.isAxiosError.
  • Dukungan Progress Tracking: Menggunakan XMLHTTPRequest di bawah kap yang mendukung fitur pelacakan progress upload/download file secara native dan akurat.
  • Request Cancellation: Mendukung pembatalan request menggunakan AbortController (atau legacy CancelToken) untuk menghindari kebocoran memori.

🔴 Kekurangan (Disadvantages)

  • Ukuran Bundel (Bundle Size): Memiliki ukuran footprint yang relatif besar (~11.7kB gzipped) dibandingkan native fetch atau client berbasis fetch modern.
  • Bukan Native Web Standard: Mengandalkan pustaka pihak ketiga yang harus di-maintain secara berkala di package.json untuk menghindari celah keamanan.
  • Runtime Dependency di Edge Environment: Di runtime modern seperti Cloudflare Workers atau Vercel Edge, API berbasis Axios terkadang membutuhkan polyfill karena tidak berjalan sepenuhnya di atas standard Fetch API.

Opsi Alternatif Selain Axios

Jika proyek membutuhkan alternatif HTTP Client yang lebih ringan atau yang mengadopsi standard web modern (Fetch-based), berikut adalah 3 opsi terbaik:

A. Native Fetch API

API bawaan browser modern untuk melakukan HTTP request tanpa bergantung pada pustaka pihak ketiga.

  • Keunggulan (Advantages):

    • Zero Dependency: Tidak menambah ukuran bundle size aplikasi klien sama sekali.
    • Standard Platform: Didukung secara native di semua browser modern dan runtime server/Edge (Node.js 18+, Bun, Deno, Cloudflare Workers).
    • Integrasi Framework: Sangat optimal untuk Server Components (RSC) di Next.js karena integrasi caching bawaan framework berjalan di atas native fetch.
  • Kekurangan (Disadvantages):

    • Tanpa Interceptor: Tidak mendukung request/response interceptor bawaan; developer harus membungkus fetch secara manual.
    • Error Handling Manual: Tidak melempar error pada status HTTP 4xx atau 5xx. Developer harus mengecek status response.ok secara manual.
    • Proses JSON Dua Tahap: Harus memanggil .json() secara terpisah setelah mendapatkan response (misalnya: const data = await res.json()).
    • Tidak Ada Request Timeout Bawaan: Membutuhkan penulisan kode manual menggunakan AbortController atau AbortSignal.timeout.
Contoh Implementasi Wrapper Fetch API dengan TypeScript:
interface FetchOptions extends RequestInit {
  timeout?: number;
  _retry?: boolean;
}

export const nativeClient = {
  async request<T>(path: string, options: FetchOptions = {}): Promise<T> {
    const { timeout = 15000, _retry = false, ...customOptions } = options;
    const controller = new AbortController();
    const id = setTimeout(() => controller.abort(), timeout);

    const config: RequestInit = {
      ...customOptions,
      signal: controller.signal,
      credentials: "include", // Cookie Better Auth mengalir otomatis
      headers: {
        "Content-Type": "application/json",
        Accept: "application/json",
        ...customOptions.headers,
      },
    };

    try {
      const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}${path}`, config);
      clearTimeout(id);

      // Tangani token expired (401) dan lakukan silent refresh token
      if (response.status === 401 && !_retry) {
        try {
          await fetch(`${process.env.NEXT_PUBLIC_API_URL}/auth/refresh`, {
            method: "POST",
            credentials: "include",
          });
          // Ulangi request asli secara rekursif
          return this.request<T>(path, { ...options, _retry: true });
        } catch (refreshError) {
          if (typeof window !== "undefined") {
            const redirectPath = window.location.pathname + window.location.search;
            window.location.href = `/login?message=session_expired&callbackUrl=${encodeURIComponent(redirectPath)}`;
          }
          throw refreshError;
        }
      }

      if (!response.ok) {
        const errData = await response.json().catch(() => ({}));
        throw {
          message: errData.message || "Terjadi kesalahan pada server",
          status: response.status,
          code: errData.code || "SERVER_ERROR",
          errors: errData.errors || null,
        };
      }

      return await response.json();
    } catch (error: any) {
      clearTimeout(id);
      if (error.name === "AbortError") {
        throw { message: "Request timeout", status: 408, code: "TIMEOUT", errors: null };
      }
      throw error;
    }
  },

  get<T>(path: string, options?: FetchOptions) {
    return this.request<T>(path, { ...options, method: "GET" });
  },

  postJson<T>(path: string, data: any, options?: FetchOptions) {
    return this.request<T>(path, { ...options, method: "POST", body: JSON.stringify(data) });
  },
};

B. Ky

Pustaka HTTP Client minimalis (~3kB gzipped) yang dibangun langsung di atas native Fetch API, dikembangkan oleh Sindre Sorhus.

  • Keunggulan (Advantages):

    • Sangat Ringan: Ukuran bundel hanya sepertiga dari Axios, meminimalkan overhead loading awal.
    • Fetch-based & Modern: Bekerja out-of-the-box di runtime Edge dan modern server environments.
    • Dukungan Hooks (Interceptors): Memiliki sistem lifecycle hooks seperti beforeRequest dan afterResponse yang fungsinya mirip interceptor Axios.
    • Auto-Retry & Timeout: Dilengkapi fitur retry otomatis pada kegagalan koneksi/status code tertentu dan penanganan timeout bawaan.
    • Error Handling Otomatis: Otomatis melempar error HTTPError jika status response di luar rentang 2xx.
  • Kekurangan (Disadvantages):

    • Membutuhkan Lingkungan Fetch: Membutuhkan runtime yang mendukung Fetch secara native atau polyfill di browser yang sangat jadul.
    • Tidak Mendukung Progress Upload: Karena berbasis Fetch, tidak memiliki API native untuk melacak progress upload file sebesar XHR.
Contoh Implementasi Ky Instance dengan TypeScript:
import ky from "ky";

const apiKy = ky.create({
  prefixUrl: process.env.NEXT_PUBLIC_API_URL || "https://api.my-app.com/v1",
  timeout: 15000,
  credentials: "include", // Cookie Better Auth mengalir otomatis
  headers: {
    Accept: "application/json",
    "Content-Type": "application/json",
  },
  hooks: {
    beforeRequest: [
      (request) => {
        request.headers.set("X-Requested-With", "XMLHttpRequest");
      },
    ],
    afterResponse: [
      async (request, options, response) => {
        if (response.status === 401) {
          try {
            // Panggil endpoint refresh token secara silent
            await ky.post(`${process.env.NEXT_PUBLIC_API_URL}/auth/refresh`, {
              credentials: "include",
            });
            // Mengembalikan request baru agar Ky otomatis mengulang request asal
            return ky(request);
          } catch (refreshError) {
            if (typeof window !== "undefined") {
              const path = window.location.pathname + window.location.search;
              window.location.href = `/login?message=session_expired&callbackUrl=${encodeURIComponent(path)}`;
            }
          }
        }
      },
    ],
  },
});

export const kyClient = {
  get<T>(path: string) {
    return apiKy.get(path).json<T>();
  },
  postJson<T>(path: string, data: any) {
    return apiKy.post(path, { json: data }).json<T>();
  },
  postForm<T>(path: string, formData: FormData) {
    return apiKy.post(path, { body: formData }).json<T>();
  },
};

C. Wretch

Wrapper Fetch API yang sangat ringan (~3kB) dengan arsitektur modular (plugin-based) dan sintaksis bergaya fluent/chainable.

  • Keunggulan (Advantages):

    • Fluent API: Penulisan chainable yang sangat ekspresif (misalnya: wretch(url).get().json()).
    • Penanganan Error Granular: Memiliki method khusus untuk menangani status code tertentu secara langsung (seperti .unauthorized(), .forbidden(), .notFound()).
    • Arsitektur Modular: Menyediakan plugin tambahan (retry, middleware, abort) secara terpisah sehingga Anda hanya memuat fitur yang benar-benar digunakan.
  • Kekurangan (Disadvantages):

    • Sintaksis Berbeda: Menggunakan pola penulisan berantai yang tidak umum bagi kebanyakan developer React/TS.
    • Boilerplate untuk Fitur Tambahan: Mengaktifkan fitur seperti retry atau interceptor memerlukan instalasi plugin tambahan dan pendaftaran middleware manual.
Contoh Implementasi Wretch Instance dengan TypeScript:
import wretch from "wretch";
import QueryResolverAddon from "wretch/addons/queryString";

const apiWretch = wretch(process.env.NEXT_PUBLIC_API_URL || "https://api.my-app.com/v1")
  .addon(QueryResolverAddon)
  .options({
    credentials: "include", // Cookie Better Auth mengalir otomatis
    headers: {
      Accept: "application/json",
      "Content-Type": "application/json",
    },
  });

export const wretchClient = {
  get<T>(path: string, query?: Record<string, any>) {
    let request = apiWretch.url(path);
    if (query) {
      request = request.query(query);
    }
    return request
      .get()
      .unauthorized(async (error, req) => {
        try {
          // Panggil endpoint refresh token secara silent
          await wretch(`${process.env.NEXT_PUBLIC_API_URL}/auth/refresh`).post().res();
          // Mengulang request asal dengan replay()
          return req.replay().json();
        } catch (refreshError) {
          if (typeof window !== "undefined") {
            const redirectPath = window.location.pathname + window.location.search;
            window.location.href = `/login?message=session_expired&callbackUrl=${encodeURIComponent(redirectPath)}`;
          }
          throw refreshError;
        }
      })
      .json<T>();
  },

  postJson<T>(path: string, data: any) {
    return apiWretch.url(path).post(data).json<T>();
  },
};

Tabel Perbandingan HTTP Clients

KriteriaAxiosNative Fetch APIKyWretch
Ukuran BundelCukup Berat (~11.7kB)Zero (0kB - Native)Sangat Ringan (~3kB)Sangat Ringan (~3kB)
Gaya SintaksisPromise & ConfigurationPromise / RequestInitPromise / FluentFluent (Chainable)
Edge Runtime ReadyTerbatas (Butuh polyfills)Ya (Native)YaYa
Interceptors / HooksSangat Matang (Built-in)Manual / Custom WrapperYa (hooks API)Ya (via middleware plugin)
Penanganan ErrorOtomatis (> 2xx)Manual (response.ok)Otomatis (HTTPError)Sangat Granular (per status)
Auto-Retry & TimeoutButuh custom plugin/configManual (via AbortController)Bawaan & MudahYa (via addon plugin)
Upload ProgressYa (Native XHR)Tidak AdaTidak AdaTidak Ada

Panduan Memilih HTTP Client (Decision Guide)

  1. Pilihlah Axios jika:

    • Proyek Anda membutuhkan tracking upload/download progress yang detail (seperti visualisasi progress bar pengunggahan file secara real-time).
    • Tim Anda sangat familiar dengan interceptor Axios dan ingin menggunakan library standar industri yang paling stabil.
  2. Pilihlah Native Fetch jika:

    • Ukuran bundel aplikasi adalah prioritas mutlak (zero bundle overhead).
    • Anda menggunakan Next.js App Router secara intensif dan ingin memaksimalkan performa caching bawaan Next.js di Server Components.
  3. Pilihlah Ky jika:

    • Anda ingin fitur-fitur Axios (seperti interceptor, timeout, auto-retry, status error throwing) tetapi dalam ukuran bundel yang jauh lebih ringan (~3kB) dan berjalan mulus di Edge Runtime.
  4. Pilihlah Wretch jika:

    • Anda menyukai penulisan kode asinkronus bergaya functional/fluent yang chainable dan membutuhkan penanganan kode error HTTP (seperti 401, 404, 500) secara terisolasi dan rapi.

© 2026 Bit Group Asia. All rights reserved.

ArchitectureSecurityGuidelines