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
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 APIDengan demikian, halaman tidak perlu mengetahui bagaimana API Client dikonfigurasi.
Mengapa Tidak Langsung Membuat useApi()?
Salah satu pendekatan yang sering digunakan adalah membuat:
composables/
└── useApi.tsKemudian 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 transformationKarena 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/
PresentationAliran datanya menjadi:
Page
│
▼
Composable
│
▼
API Service
│
▼
API Client
│
▼
Laravel APIDengan 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/apiSedangkan production:
NUXT_PUBLIC_API_BASE=https://api.example.com/apiDengan cara tersebut, source code tidak perlu diubah ketika domain API berubah antara development dan production.
Membuat Custom $fetch
Sekarang buat plugin:
app/plugins/api.tsKemudian:
export default defineNuxtPlugin(() => {
const config = useRuntimeConfig()
const api = $fetch.create({
baseURL: config.public.apiBase
})
return {
provide: {
api
}
}
})Nuxt kemudian menyediakan instance tersebut melalui:
$apiAPI 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/jsonDengan 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
onResponseErrorContohnya:
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 Unauthorizedatau:
403 ForbiddenKita 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.tsKemudian:
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.tsKemudian:
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 APIDengan 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?.datamerupakan:
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=10Composable 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 = 2berubah, 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 parameterContohnya:
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
SSRSehingga pembagian tanggung jawabnya menjadi:
Service
↓
API communication
Composable
↓
Vue/Nuxt data statePemisahan 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
↓
$apiPemilihannya 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.vueAliran 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
↓
LaravelNamun ketika jumlah endpoint mulai banyak, struktur berikut lebih mudah dipelihara:
Page
↓
Composable
↓
Service
↓
$api
↓
LaravelJangan 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.tsdengan 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.tsSetiap 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 errorJangan 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
↓
UIDengan 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 handlingcustom $fetch biasanya memberikan kontrol yang lebih sesuai untuk membangun API Client.
Security Consideration
Jangan menyimpan secret backend pada:
runtimeConfig.publicContohnya:
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
CORSKarena 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 UnauthorizedAPI Client kemudian dapat:
401
↓
Refresh Token
↓
Access Token Baru
↓
Retry RequestNamun terdapat masalah ketika banyak request gagal secara bersamaan.
Misalnya:
Request 1 → 401
Request 2 → 401
Request 3 → 401
Request 4 → 401
Request 5 → 401Jika setiap request menjalankan refresh token sendiri:
5 API request
↓
5 refresh requesthal 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 APIGunakan:
useFetch()untuk initial data yang membutuhkan integrasi dengan Nuxt dan SSR.$fetch()atau custom$apiuntuk mutation dan event-driven request.runtimeConfiguntuk konfigurasi environment.Custom
$fetchuntuk 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
↓
Laravelsudah cukup.
Sedangkan aplikasi yang memiliki banyak resource dan endpoint dapat menggunakan:
Page
↓
Composable
↓
Service
↓
API Client
↓
LaravelDengan 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
useFetchCara Membuat Reusable API Composable di Nuxt JS 4
Baca juga artikel serupa :

