Cara Membuat API Client di Nuxt JS 4 dengan Custom $fetch

Pelajari cara membuat API Client di Nuxt JS 4 menggunakan custom $fetch, runtimeConfig, interceptor, error handling, dan struktur API yang scalable.

Cara Membuat API Client di Nuxt JS 4 dengan Custom $fetch
Cara Membuat API Client di Nuxt JS 4 dengan Custom $fetch • Tutorial

Cara Membuat API Client di Nuxt JS 4 dengan Custom $fetch

Setelah memahami reusable API composable di Nuxt JS 4, langkah berikutnya adalah membuat API layer yang lebih terstruktur.

Pada aplikasi sederhana, penggunaan useFetch() dan $fetch() secara langsung mungkin sudah cukup.

Namun ketika aplikasi mulai memiliki banyak fitur, API request biasanya mulai memiliki kebutuhan tambahan seperti:

  • Base URL API.

  • Authentication header.

  • Interceptor.

  • Error handling.

  • Authorization.

  • Refresh token.

  • Request timeout.

  • Endpoint yang terorganisir.

  • Konsistensi response API.

Jika semua logic tersebut ditulis langsung di halaman atau composable, struktur aplikasi dapat menjadi sulit dipelihara.

Solusinya adalah membuat API Client.

Dengan API Client, konfigurasi dan logic komunikasi dengan backend dapat dipusatkan sehingga halaman dan komponen tidak perlu menangani detail HTTP secara berulang.


Apa Itu API Client?

API Client adalah abstraction yang digunakan untuk menangani komunikasi antara frontend dengan backend API.

Misalnya backend Laravel memiliki endpoint:

GET    /api/articles
GET    /api/articles/{slug}
POST   /api/articles
PUT    /api/articles/{id}
DELETE /api/articles/{id}

Tanpa API Client, halaman dapat berisi:

const config = useRuntimeConfig()

const response = await $fetch(
  `${config.public.apiBase}/articles`
)

Kemudian halaman lain:

const config = useRuntimeConfig()

const response = await $fetch(
  `${config.public.apiBase}/categories`
)

Kemudian halaman lain lagi:

const config = useRuntimeConfig()

const response = await $fetch(
  `${config.public.apiBase}/products`
)

Masalahnya bukan pada $fetch.

Masalahnya adalah logic komunikasi API mulai tersebar di banyak tempat.

Dengan API Client, kita dapat membuat satu layer yang menangani konfigurasi HTTP.

Strukturnya menjadi:

Page
 │
 ▼
Composable
 │
 ▼
API Client
 │
 ▼
Laravel API

Dengan demikian, halaman tidak perlu mengetahui bagaimana API Client dikonfigurasi.


Mengapa Tidak Langsung Membuat useApi()?

Salah satu pendekatan yang sering digunakan adalah membuat:

composables/
└── useApi.ts

Kemudian semua request dilakukan melalui:

const api = useApi()

await api('/articles')

Pendekatan tersebut dapat bekerja untuk aplikasi sederhana.

Namun pada aplikasi yang semakin besar, kita biasanya membutuhkan konfigurasi yang lebih terpusat seperti:

Base URL
Authentication
Headers
Timeout
Interceptors
Error handling
Request transformation
Response transformation

Karena itu, kita dapat membuat custom $fetch instance sebagai HTTP Client.

Nuxt menggunakan $fetch yang berbasis ofetch, sedangkan useFetch digunakan untuk mengintegrasikan data fetching dengan mekanisme Nuxt seperti SSR dan payload hydration.


Menentukan Struktur API Layer

Untuk aplikasi Nuxt JS 4 yang terhubung ke Laravel API, struktur yang cukup scalable dapat dibuat seperti:

app/
├── composables/
│   ├── useArticles.ts
│   └── useArticle.ts
│
├── services/
│   ├── article.service.ts
│   ├── category.service.ts
│   └── auth.service.ts
│
├── plugins/
│   └── api.ts
│
└── pages/

Pembagian tanggung jawabnya:

composables/
    Data fetching untuk UI

services/
    Endpoint API

plugins/
    Konfigurasi HTTP Client

pages/
    Presentation

Aliran datanya menjadi:

Page
 │
 ▼
Composable
 │
 ▼
API Service
 │
 ▼
API Client
 │
 ▼
Laravel API

Dengan struktur tersebut, setiap layer memiliki tanggung jawab yang lebih jelas.


Menyiapkan Runtime Config

Pertama, simpan URL backend Laravel pada environment.

Misalnya:

NUXT_PUBLIC_API_BASE=https://api.example.com/api

Kemudian konfigurasi Nuxt:

export default defineNuxtConfig({
  runtimeConfig: {
    public: {
      apiBase: ''
    }
  }
})

Pada development:

NUXT_PUBLIC_API_BASE=http://localhost:8000/api

Sedangkan production:

NUXT_PUBLIC_API_BASE=https://api.example.com/api

Dengan cara tersebut, source code tidak perlu diubah ketika domain API berubah antara development dan production.


Membuat Custom $fetch

Sekarang buat plugin:

app/plugins/api.ts

Kemudian:

export default defineNuxtPlugin(() => {
  const config = useRuntimeConfig()

  const api = $fetch.create({
    baseURL: config.public.apiBase
  })

  return {
    provide: {
      api
    }
  }
})

Nuxt kemudian menyediakan instance tersebut melalui:

$api

API Client dapat digunakan seperti:

const { $api } = useNuxtApp()

const articles = await $api('/articles')

Keuntungan utamanya adalah base URL tidak perlu ditulis berulang kali pada setiap request.


Menambahkan Default Header

API Client biasanya membutuhkan header standar.

Misalnya:

export default defineNuxtPlugin(() => {
  const config = useRuntimeConfig()

  const api = $fetch.create({
    baseURL: config.public.apiBase,

    headers: {
      Accept: 'application/json'
    }
  })

  return {
    provide: {
      api
    }
  }
})

Sekarang request melalui $api secara otomatis memiliki:

Accept: application/json

Dengan demikian, header yang bersifat global tidak perlu ditulis pada setiap endpoint.


Menggunakan Interceptor

Custom $fetch menjadi semakin berguna ketika aplikasi membutuhkan interceptor.

Beberapa lifecycle yang tersedia antara lain:

onRequest
onRequestError
onResponse
onResponseError

Contohnya:

export default defineNuxtPlugin(() => {
  const config = useRuntimeConfig()

  const api = $fetch.create({
    baseURL: config.public.apiBase,

    onRequest({ options }) {
      options.headers.set(
        'Accept',
        'application/json'
      )
    },

    onResponse({ response }) {
      console.log(
        'API Response:',
        response.status
      )
    },

    onResponseError({ response }) {
      console.error(
        'API Error:',
        response.status
      )
    }
  })

  return {
    provide: {
      api
    }
  }
})

Sekarang setiap request melewati lifecycle yang sama.

Hal ini sangat berguna ketika kita membutuhkan logic global seperti authentication, logging, error handling, atau response processing.


Menambahkan Authentication Header

Misalnya Laravel API menggunakan Bearer Token.

Token dapat diperoleh dari cookie atau mekanisme authentication yang digunakan aplikasi.

Contoh sederhana:

export default defineNuxtPlugin(() => {
  const config = useRuntimeConfig()

  const token = useCookie<string | null>(
    'access_token'
  )

  const api = $fetch.create({
    baseURL: config.public.apiBase,

    onRequest({ options }) {
      options.headers.set(
        'Accept',
        'application/json'
      )

      if (token.value) {
        options.headers.set(
          'Authorization',
          `Bearer ${token.value}`
        )
      }
    }
  })

  return {
    provide: {
      api
    }
  }
})

Sekarang halaman tidak perlu lagi menulis:

headers: {
  Authorization: `Bearer ${token}`
}

pada setiap request.

Ini merupakan salah satu manfaat utama interceptor.

Namun implementasi authentication production-ready perlu mempertimbangkan SSR, cookie security, access token, refresh token, CSRF, dan CORS.


Menangani HTTP Error

Misalnya Laravel mengembalikan:

401 Unauthorized

atau:

403 Forbidden

Kita dapat menangani response error secara terpusat.

export default defineNuxtPlugin(() => {
  const config = useRuntimeConfig()

  const api = $fetch.create({
    baseURL: config.public.apiBase,

    onResponseError({ response }) {
      if (response.status === 401) {
        console.log('Unauthorized')
      }

      if (response.status === 403) {
        console.log('Forbidden')
      }
    }
  })

  return {
    provide: {
      api
    }
  }
})

Dengan pendekatan tersebut, logic yang bersifat global tidak perlu diulang di setiap halaman.

Namun perlu diperhatikan bahwa 401 tidak selalu berarti pengguna harus langsung logout.

Dalam aplikasi yang menggunakan access token dan refresh token, response 401 biasanya dapat menjadi trigger untuk menjalankan mekanisme refresh token terlebih dahulu.

Pembahasan refresh token sebaiknya dibuat dalam artikel terpisah karena membutuhkan perhatian terhadap concurrent request dan race condition.


Membuat Service untuk Endpoint Artikel

Sekarang kita masuk ke bagian yang lebih penting.

Jangan membuat semua endpoint langsung di halaman.

Buat file:

app/services/article.service.ts

Kemudian:

export const articleService = {
  list() {
    const { $api } = useNuxtApp()

    return $api('/articles')
  },

  findBySlug(slug: string) {
    const { $api } = useNuxtApp()

    return $api(`/articles/${slug}`)
  }
}

Sekarang endpoint artikel berada di satu tempat.

Halaman tidak perlu mengetahui URL API secara langsung.


Menggunakan Service dari Composable

Kita dapat menggabungkan service dengan composable.

Misalnya:

app/composables/useArticles.ts

Kemudian:

export const useArticles = () => {
  const { $api } = useNuxtApp()

  return useFetch('/articles', {
    $fetch: $api
  })
}

Dengan pola tersebut, useFetch tetap digunakan untuk kebutuhan data fetching Nuxt, tetapi request menggunakan custom $fetch.

Aliran datanya menjadi:

useArticles()
     │
     ▼
useFetch()
     │
     ▼
$api
     │
     ├── baseURL
     ├── headers
     ├── authentication
     ├── interceptor
     └── error handling
     │
     ▼
Laravel API

Dengan demikian, halaman tetap mendapatkan keuntungan dari useFetch, sementara konfigurasi HTTP berada pada API Client.


Contoh Service dengan TypeScript

Misalnya backend Laravel mengembalikan response:

{
  "data": [
    {
      "id": 1,
      "title": "Belajar Nuxt JS 4"
    }
  ]
}

Kita dapat membuat interface:

interface Article {
  id: number
  title: string
  slug: string
  content: string
}

interface ArticleResponse {
  data: Article[]
}

Kemudian service:

export const articleService = {
  list() {
    const { $api } = useNuxtApp()

    return $api<ArticleResponse>('/articles')
  }
}

Sekarang TypeScript mengetahui struktur response dari endpoint tersebut.


Menggunakan TypeScript pada Composable

Composable juga dapat menggunakan typing yang sama:

export const useArticles = () => {
  const { $api } = useNuxtApp()

  return useFetch<ArticleResponse>('/articles', {
    $fetch: $api
  })
}

Kemudian pada halaman:

const { data, status, error } = await useArticles()

TypeScript sekarang mengetahui bahwa:

data.value?.data

merupakan:

Article[]

Penggunaan TypeScript menjadi semakin penting ketika struktur response API semakin kompleks.


API Client untuk Pagination

Misalnya Laravel API memiliki endpoint:

GET /articles?page=1&per_page=10

Composable dapat dibuat seperti:

interface ArticleOptions {
  page?: MaybeRefOrGetter<number>
  perPage?: MaybeRefOrGetter<number>
}

export const useArticles = (
  options: ArticleOptions = {}
) => {
  const { $api } = useNuxtApp()

  return useFetch<ArticleResponse>('/articles', {
    $fetch: $api,

    query: {
      page: options.page,
      per_page: options.perPage
    }
  })
}

Kemudian digunakan:

const page = ref(1)

const {
  data,
  status,
  error
} = await useArticles({
  page,
  perPage: 10
})

Ketika nilai:

page.value = 2

berubah, nilai reactive tersebut dapat digunakan oleh useFetch untuk melakukan request ulang.

Pendekatan ini berguna untuk berbagai resource seperti:

  • Artikel.

  • Produk.

  • Pengguna.

  • Transaksi.

  • Kategori.

  • Order.


Kapan Menggunakan Service dan Kapan Menggunakan Composable?

Service dan composable memiliki tanggung jawab yang berbeda.

Service

Service bertanggung jawab terhadap komunikasi endpoint API:

Endpoint API
Request
HTTP method
Payload
Query parameter

Contohnya:

articleService.list()
articleService.findBySlug()
articleService.create()
articleService.update()
articleService.delete()

Composable

Composable bertanggung jawab terhadap integrasi dengan Vue dan Nuxt:

Reactive state
useFetch
useAsyncData
Loading
Error
Refresh
SSR

Sehingga pembagian tanggung jawabnya menjadi:

Service
    ↓
API communication

Composable
    ↓
Vue/Nuxt data state

Pemisahan ini membuat architecture lebih mudah dikembangkan.


Mengapa Tidak Menggunakan $fetch untuk Semua Request?

Pertanyaan ini cukup penting.

Misalnya:

const articles = await $api('/articles')

Secara teknis request tersebut dapat dilakukan.

Namun untuk initial data pada halaman Nuxt, kita perlu mempertimbangkan SSR.

Jika $fetch digunakan secara langsung untuk mengambil data halaman, request dapat dilakukan ketika SSR dan kemudian dilakukan lagi ketika hydration karena hasil request $fetch tidak otomatis ditempatkan ke payload Nuxt.

Karena itu, untuk initial page data lebih tepat menggunakan:

useFetch()

atau:

useAsyncData()

dengan $fetch.

Sedangkan $fetch lebih cocok untuk request yang dipicu oleh event seperti submit form.

Contoh:

async function createArticle(
  payload: CreateArticlePayload
) {
  const { $api } = useNuxtApp()

  return $api('/articles', {
    method: 'POST',
    body: payload
  })
}

Jadi pola sederhananya:

GET initial page data
        ↓
    useFetch

POST form
        ↓
      $api

PUT update
        ↓
      $api

DELETE
        ↓
      $api

Pemilihannya bukan karena salah satu syntax lebih sederhana, tetapi berdasarkan lifecycle dan kebutuhan data pada aplikasi.


Struktur Final API Layer

Setelah API Client dibuat, struktur aplikasi dapat menjadi:

app/
├── components/
│
├── composables/
│   ├── useArticles.ts
│   └── useArticle.ts
│
├── services/
│   ├── article.service.ts
│   ├── category.service.ts
│   └── auth.service.ts
│
├── plugins/
│   └── api.ts
│
├── pages/
│   ├── articles/
│   │   ├── index.vue
│   │   └── [slug].vue
│   │
│   └── ...
│
└── app.vue

Aliran datanya:

┌──────────────┐
│     Page     │
└──────┬───────┘
       │
       ▼
┌──────────────┐
│  Composable  │
└──────┬───────┘
       │
       ▼
┌──────────────┐
│ API Service  │
└──────┬───────┘
       │
       ▼
┌──────────────┐
│ API Client   │
│    ($api)    │
└──────┬───────┘
       │
       ├── Base URL
       ├── Headers
       ├── Authentication
       ├── Interceptor
       └── Error handling
       │
       ▼
┌──────────────┐
│ Laravel API  │
└──────────────┘

Struktur ini membuat setiap layer memiliki tanggung jawab yang jelas.


Apakah Harus Menggunakan Service Layer?

Tidak selalu.

Untuk aplikasi kecil, struktur berikut mungkin sudah cukup:

Page
 ↓
useFetch
 ↓
$api
 ↓
Laravel

Namun ketika jumlah endpoint mulai banyak, struktur berikut lebih mudah dipelihara:

Page
 ↓
Composable
 ↓
Service
 ↓
$api
 ↓
Laravel

Jangan membuat service layer hanya karena architecture terlihat lebih kompleks atau profesional.

Gunakan service layer ketika memang terdapat domain atau endpoint logic yang mulai bertambah.

Architecture yang baik bukan architecture yang paling banyak layer, tetapi architecture yang memiliki pembagian tanggung jawab yang jelas.


Jangan Membuat Satu api.ts untuk Semua Endpoint

Hindari struktur seperti:

services/
└── api.ts

dengan isi:

getArticles()
getArticle()
createArticle()
updateArticle()
deleteArticle()

getProducts()
getProduct()
createProduct()

getUsers()
getUser()
createUser()

Ketika aplikasi berkembang, file tersebut dapat berubah menjadi God Service.

Lebih baik mengelompokkan endpoint berdasarkan domain:

services/
├── article.service.ts
├── product.service.ts
├── user.service.ts
├── category.service.ts
└── auth.service.ts

Setiap service memiliki boundary yang lebih jelas.


API Client Bukan Tempat Menaruh Semua Business Logic

API Client sebaiknya fokus pada HTTP concern seperti:

Base URL
Headers
Authentication
Timeout
Interceptor
HTTP error

Jangan memasukkan seluruh business logic ke dalam API Client.

Contoh yang kurang tepat:

$api('/articles', {
  onResponse() {
    // business calculation
    // business status
    // business transformation
  }
})

Lebih baik:

API Client
    ↓
Raw API Response
    ↓
Service / Domain Logic
    ↓
Composable
    ↓
UI

Dengan demikian API Client tetap generic dan dapat digunakan oleh berbagai domain.


Kapan Menggunakan createUseFetch()?

Selain custom $fetch, Nuxt juga menyediakan:

createUseFetch()

API tersebut dapat digunakan untuk membuat custom useFetch dengan konfigurasi default tertentu.

Misalnya:

export const useAPI = createUseFetch({
  baseURL: 'https://api.example.com'
})

Kemudian:

const { data } = await useAPI('/articles')

Pendekatan tersebut cocok ketika kebutuhan utama kita adalah membuat wrapper useFetch dengan konfigurasi default.

Namun jika aplikasi membutuhkan HTTP layer yang lebih kompleks seperti:

Authentication
Interceptor
401 handling
Refresh token
Retry request
Global error handling

custom $fetch biasanya memberikan kontrol yang lebih sesuai untuk membangun API Client.


Security Consideration

Jangan menyimpan secret backend pada:

runtimeConfig.public

Contohnya:

runtimeConfig: {
  public: {
    apiSecret: 'secret'
  }
}

Pendekatan tersebut tidak aman karena property public dapat tersedia pada sisi client.

Gunakan private runtime config untuk secret yang hanya dibutuhkan server:

runtimeConfig: {
  apiSecret: ''
}

Sedangkan configuration seperti API base URL yang memang perlu diketahui browser dapat berada pada:

runtimeConfig: {
  public: {
    apiBase: ''
  }
}

Bagaimana dengan SSR Authentication?

Authentication pada Nuxt membutuhkan perhatian khusus ketika aplikasi menggunakan SSR.

Ketika request dilakukan dari browser, browser dapat mengirim cookie sesuai konfigurasi cookie.

Namun ketika Nuxt melakukan SSR, request dilakukan oleh server Nuxt.

Karena itu, authentication berbasis cookie tidak selalu dapat diperlakukan sama seperti request dari browser.

Nuxt menyediakan mekanisme seperti useRequestFetch() untuk membantu meneruskan request context pada server-side fetching.

Hal ini penting terutama jika Laravel API menggunakan authentication berbasis cookie.

Untuk aplikasi production, authentication layer perlu mempertimbangkan:

SSR
CSR
Cookie
Access Token
Refresh Token
HttpOnly
SameSite
CSRF
CORS

Karena itu, authentication sebaiknya tidak hanya diselesaikan dengan menambahkan Authorization header pada interceptor.


Authentication dan Refresh Token

Setelah API Client memiliki interceptor, kita dapat mengembangkan authentication layer.

Misalnya access token sudah expired:

Request
   ↓
Laravel API
   ↓
401 Unauthorized

API Client kemudian dapat:

401
 ↓
Refresh Token
 ↓
Access Token Baru
 ↓
Retry Request

Namun terdapat masalah ketika banyak request gagal secara bersamaan.

Misalnya:

Request 1 → 401
Request 2 → 401
Request 3 → 401
Request 4 → 401
Request 5 → 401

Jika setiap request menjalankan refresh token sendiri:

5 API request
      ↓
5 refresh request

hal tersebut dapat menyebabkan race condition dan request yang tidak diperlukan.

Solusi yang lebih baik adalah menggunakan satu proses refresh yang dapat digunakan bersama oleh request lain.

Konsep ini biasanya dikenal sebagai single-flight refresh atau refresh request queue.

Implementasinya membutuhkan pembahasan khusus karena harus menangani:

  • Concurrent request.

  • Request queue.

  • Token rotation.

  • Retry request.

  • Refresh token failure.

  • Logout.

  • Infinite retry loop.

Karena itu, refresh token sebaiknya dibuat sebagai bagian dari authentication layer, bukan dimasukkan secara sembarangan ke setiap composable.


Kesimpulan

Reusable API composable merupakan langkah awal untuk mengurangi duplikasi API fetching.

Namun ketika aplikasi Nuxt JS 4 semakin besar, kita membutuhkan API layer yang lebih terstruktur.

Pola yang dapat digunakan adalah:

Page
 ↓
Composable
 ↓
Service
 ↓
API Client
 ↓
Laravel API

Gunakan:

  • useFetch() untuk initial data yang membutuhkan integrasi dengan Nuxt dan SSR.

  • $fetch() atau custom $api untuk mutation dan event-driven request.

  • runtimeConfig untuk konfigurasi environment.

  • Custom $fetch untuk membuat HTTP Client.

  • Interceptor untuk authentication dan error handling.

  • Service untuk mengelompokkan endpoint berdasarkan domain.

  • Composable untuk menghubungkan API data dengan reactive state Nuxt.

  • TypeScript untuk menjaga kontrak request dan response.

  • SSR-aware authentication ketika API membutuhkan cookie atau token.

Jangan membuat abstraction terlalu kompleks hanya untuk mengikuti pattern tertentu.

Untuk aplikasi kecil, struktur sederhana:

Page
 ↓
Composable
 ↓
API Client
 ↓
Laravel

sudah cukup.

Sedangkan aplikasi yang memiliki banyak resource dan endpoint dapat menggunakan:

Page
 ↓
Composable
 ↓
Service
 ↓
API Client
 ↓
Laravel

Dengan struktur tersebut, aplikasi Nuxt JS 4 menjadi lebih mudah dipelihara ketika jumlah halaman, endpoint, dan fitur authentication semakin bertambah.

Pada tutorial berikutnya, kita dapat melanjutkan API Client ini menjadi Authentication API Client di Nuxt JS 4 dengan Access Token dan Refresh Token, termasuk interceptor 401, request queue, retry request, dan pencegahan multiple refresh request.

Baca juga

  • Cara Install Nuxt.js Terbaru (Nuxt 4) untuk Pemula

  • Cara Mengambil Data API di Nuxt JS 4 dengan useFetch

  • Cara Membuat Reusable API Composable di Nuxt JS 4

Baca juga artikel serupa :

  1. Cara Install Nuxt.js Terbaru (Nuxt 4) untuk Pemula

  2. Cara Mengambil Data API di Nuxt JS 4 dengan useFetch

  3. Cara Membuat Reusable API Composable di Nuxt JS 4

Tautan Artikel:
Ananda Resa Ardiansyah
Profil Penulis
Ananda Resa Ardiansyah

Web Developer

Fullstack Web Developer specializing in Laravel, React, and Next.js, with a focus on performance, scalability, and clean architecture.