@alikhalilll/nuxt-api-provider
A strongly-typed fetch client for Nuxt 3 / 4 with interceptors, retry/backoff, and a framework-agnostic core you can use anywhere.
- Typed client type —
ApiProviderClientis exported so you can annotate your own wrappers and composables. - Interceptor chain — register multiple request/response/error interceptors via
.useRequest,.useResponse,.useError. - Retry + backoff — per-client defaults with per-call overrides. Configurable status codes and exponential delay.
- Smart body encoding — plain objects → JSON;
FormData/URLSearchParams/Blob/ArrayBuffer/stringpass through with correct Content-Type. - Timeouts + abort — per-call timeout plus
AbortSignalsupport viaAbortSignal.anywith a polyfill fallback. - Upload + download progress — a single
onRequestProgresshook. The client transparently switches toXMLHttpRequestonly when you pass it. ApiErrorclass — structured errors with.status,.details,.payload,.response, plus anisApiError(e)/ApiError.is(e)guard that works across bundles and realms.- Framework-agnostic core —
import { createApiClient } from '@alikhalilll/nuxt-api-provider/core'to use outside Nuxt.
Install
pnpm add @alikhalilll/nuxt-api-provider
Register the module
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@alikhalilll/nuxt-api-provider'],
apiProvider: {
baseURL: 'https://api.example.com',
provideName: '$apiProvider', // leading "$" optional — gets stripped
defaultTimeoutMs: 20_000,
server: true, // set false to skip SSR (client-only)
retry: { attempts: 2, delayMs: 500, backoff: 2 },
onRequestPath: '~/api/on-request',
onSuccessPath: '~/api/on-success',
onErrorPath: '~/api/on-error',
},
});
Basic usage
$apiProvider is augmented onto NuxtApp and ComponentCustomProperties as an ApiProviderClient. Wrap it in a composable to stop destructuring it everywhere:
// composables/useApi.ts
import type { ApiProviderClient } from '@alikhalilll/nuxt-api-provider/types';
export const useApi = (): ApiProviderClient => useNuxtApp().$apiProvider;
<script setup lang="ts">
interface Post {
id: number;
title: string;
}
const post = await useApi()<Post>('/posts/1');
</script>
Live · GET /posts/1
SSR (server-side rendering)
With server: true (the default), $apiProvider is available during SSR, so top-level await inside a page's <script setup> runs on the server and the HTML arrives fully populated.
<!-- pages/posts/[id].vue -->
<script setup lang="ts">
interface Post {
id: number;
title: string;
body: string;
}
const route = useRoute();
const api = useApi();
// Resolves on the server during SSR, then hydrates on the client.
const post = await api<Post>(`/posts/${route.params.id}`);
</script>
<template>
<article>
<h1>{{ post?.title }}</h1>
<p>{{ post?.body }}</p>
</article>
</template>
If you set server: false in nuxt.config.ts, $apiProvider won't exist during SSR and this pattern will throw — move the call into onMounted or useAsyncData(..., { server: false }).
useAsyncData
useAsyncData is the idiomatic way to fetch in Nuxt: it deduplicates per-key, populates the SSR payload, and exposes pending / error / refresh. Capture the client synchronously at the top of <script setup> — useNuxtApp() only works in the synchronous prefix of a setup or a Nuxt-managed callback, so reaching for it after an await throws "A composable that requires access to the Nuxt instance was called outside of …".
<script setup lang="ts">
interface Post {
id: number;
title: string;
}
const { $apiProvider } = useNuxtApp(); // capture ONCE, before any await
const { data, pending, error, refresh } = await useAsyncData('posts', () =>
$apiProvider<Post[]>('/posts')
);
</script>
<template>
<div v-if="pending">Loading…</div>
<div v-else-if="error">Failed: {{ error.message }}</div>
<ul v-else>
<li v-for="post in data" :key="post.id">{{ post.title }}</li>
</ul>
<button @click="refresh()">Reload</button>
</template>
If you factor the call into a service, pass the client in rather than calling useNuxtApp() inside the service:
// services/posts.ts
import type { ApiProviderClient } from '@alikhalilll/nuxt-api-provider/types';
export const getPost = (api: ApiProviderClient, id: string) =>
api<{ id: number; title: string }>(`/posts/${id}`);
<script setup lang="ts">
import { getPost } from '~/services/posts';
const { $apiProvider } = useNuxtApp();
const { data } = await useAsyncData('post-1', () => getPost($apiProvider, '1'));
</script>
The same function now works from a Nitro route (pass a createApiClient instance instead) — see the core section below.
Query parameters
Queries are the third argument. null/undefined/empty-string are skipped; arrays are repeated as ?tag=a&tag=b.
const posts = await api<Post[]>('/posts', null, {
userId: 1,
tag: ['news', 'featured'],
q: '', // skipped
draft: undefined, // skipped
});
POST with JSON
Plain objects (and arrays) are JSON-encoded; Content-Type: application/json is set automatically.
const created = await api<Post>('/posts', {
method: 'POST',
body: { userId: 42, title: 'Hello', body: 'World' },
});
PATCH / PUT / DELETE
await api<Post>('/posts/1', { method: 'PATCH', body: { title: 'Updated' } });
await api<Post>('/posts/1', { method: 'PUT', body: fullReplacement });
await api('/posts/1', { method: 'DELETE' });
FormData / multipart
FormData is passed through and Content-Type is dropped so the browser sets the boundary.
const form = new FormData();
form.append('file', fileInput.files[0]);
form.append('caption', 'my file');
await api<{ url: string }>('/uploads', { method: 'POST', body: form });
URL-encoded
URLSearchParams sets Content-Type: application/x-www-form-urlencoded when not already present.
const body = new URLSearchParams({ grant_type: 'refresh_token', token: rt });
await api<{ access_token: string }>('/oauth/token', { method: 'POST', body });
Timeouts and abort
// Aborts after 3s regardless of the client-level default.
await api('/slow', { timeoutMs: 3_000 });
// External AbortSignal — combined with the internal timeout signal via AbortSignal.any.
const ctrl = new AbortController();
const promise = api('/stream', { signal: ctrl.signal });
ctrl.abort();
Retry and backoff
Per-client defaults from nuxt.config.ts can be overridden per call.
await api('/flaky', {
retry: { attempts: 3, delayMs: 500, backoff: 2, statusCodes: [503] },
});
// Disable retries for this call specifically.
await api('/critical', { retry: { attempts: 0 } });
Delay for attempt n (0-indexed) is delayMs * backoff^n. Default retryable status codes: [408, 429, 500, 502, 503, 504].
Upload / download progress
Pass onRequestProgress to observe both phases. The callback receives { phase, loaded, total, ratio }. phase is 'upload' while the body is being sent and 'download' while the response is being received. total and ratio are null when the length isn't known.
<script setup lang="ts">
const uploaded = ref(0);
const uploadRatio = ref(0);
await useApi()('/uploads', {
method: 'POST',
body: form,
retry: { attempts: 0 },
timeoutMs: 60_000,
onRequestProgress: ({ phase, loaded, ratio }) => {
if (phase === 'upload') {
uploaded.value = loaded;
if (ratio !== null) uploadRatio.value = ratio;
}
},
});
</script>
Live · onRequestProgress (2 MB upload)
When onRequestProgress is set, the client swaps transport to XMLHttpRequest (the only way to observe upload progress in a browser). Interceptors, retry, timeout, AbortSignal, and ApiError still work identically. The fast path (no progress callback) stays on native fetch.
Error handling
Every failure throws an ApiError. It's the same class for HTTP errors and network errors (status === 0 means the request never reached a response).
The class implements the generic IError<TErrors, TOtherKeys> interface — a framework-agnostic contract you can use to type your own error shapes or narrow the known field names:
import type { IError } from '@alikhalilll/nuxt-api-provider/types';
type LoginError = IError<'email' | 'password', 'hint'>;
// -> { message: string; details: { errors: { email: string; password: string } } & { hint?: string | Record<string, string> } }
Discriminate caught errors with isApiError(e) (or the equivalent static ApiError.is(e)). Prefer it over instanceof ApiError: instanceof is unreliable when the package ends up duplicated in a bundle, across realms (iframes, workers), or after downleveling — isApiError uses a Symbol.for(...) brand that survives all three.
import { isApiError } from '@alikhalilll/nuxt-api-provider/types';
try {
await api('/users', { method: 'POST', body: { email: 'bad' } });
} catch (e) {
if (isApiError(e)) {
console.log(e.status); // 422
console.log(e.message); // 'Validation failed'
console.log(e.details.errors); // { email: 'Required' }
console.log(e.payload); // raw server payload
console.log(e.response); // the Response object, if any
}
}
Interceptors
There are three kinds: request, response, and error. Register them via module options (file paths with a default export) or at runtime on the client.
Authentication header
// ~/api/on-request.ts
import type { RequestInterceptor } from '@alikhalilll/nuxt-api-provider/types';
const onRequest: RequestInterceptor = (ctx) => {
const token = useCookie('token').value;
if (token) ctx.headers.Authorization = `Bearer ${token}`;
};
export default onRequest;
Error redirect
// ~/api/on-error.ts
import type { ErrorInterceptor } from '@alikhalilll/nuxt-api-provider/types';
const onError: ErrorInterceptor = (err, ctx) => {
if (err.status === 401) return navigateTo('/login');
if (ctx.meta.silent) return;
useToast().error(err.message);
};
export default onError;
Runtime registration
Useful when the interceptor depends on a composable (i18n, toast, router) that isn't available at module-setup time. useRequest/useResponse/useError each return an unregister function.
export default defineNuxtPlugin(() => {
const { $apiProvider } = useNuxtApp();
const { locale } = useI18n();
const unregister = $apiProvider.useRequest((ctx) => {
ctx.headers['X-Locale'] = locale.value;
});
});
Framework-agnostic core
Everything the Nuxt plugin wraps is available as a plain factory. Works in Node, Bun, Deno, a CLI, or a test.
import { createApiClient, isApiError } from '@alikhalilll/nuxt-api-provider/core';
const client = createApiClient({
baseURL: 'https://api.github.com',
headers: { Accept: 'application/vnd.github+json' },
retry: { attempts: 2 },
});
client.useRequest((ctx) => {
ctx.headers['User-Agent'] = 'my-cli/1.0';
if (process.env.GITHUB_TOKEN) {
ctx.headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
}
});
const repo = await client<{ stargazers_count: number }>('/repos/nuxt/nuxt');
Module options
| Option | Type | Default | Purpose |
|---|---|---|---|
baseURL | string | '' | Prepended to every relative endpoint. |
provideName | string | '$apiProvider' | Injected under $<name>. Leading $ is stripped. |
defaultTimeoutMs | number | 20000 | Client-wide request timeout. |
server | boolean | true | Register the plugin on the server. Set false for client-only. |
retry | Partial<RetryOptions> | {} | Default retry policy, overridable per call. |
onRequestPath | string | — | Path to a module with a default-exported RequestInterceptor. |
onSuccessPath | string | — | Path to a module with a default-exported ResponseInterceptor. |
onErrorPath | string | — | Path to a module with a default-exported ErrorInterceptor. |
Exported types
import type {
ApiProviderClient,
ApiProviderModuleOptions,
ApiClientConfig,
RequestOptions,
RequestContext,
ResponseContext,
RequestInterceptor,
ResponseInterceptor,
ErrorInterceptor,
RetryOptions,
RequestProgress,
ProgressPhase,
ApiError,
ApiErrorDetails,
IError,
isApiError,
} from '@alikhalilll/nuxt-api-provider/types';