@alikhalilll/nuxt-api-provider
v2.0.0
Published
Strongly-typed fetch client for Nuxt 3/4 with interceptors, retry/backoff, and a framework-agnostic core.
Maintainers
Readme
@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. - Caching, on by default — TanStack-Query-style:
staleTime30s,gcTime5min, stale-while-revalidate, request deduplication. GET/HEAD only — mutations never cached. Per-call overrides and aclient.cacheAPI for manual control. - 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, sincefetchhas no upload-progress support. 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.
Table of contents
- Install
- Register the module
- Usage
- Typed client + composable
- SSR (server-side rendering)
useAsyncData- GET
- Query parameters
- POST with JSON
- PATCH / PUT / DELETE
- Multipart / FormData upload
application/x-www-form-urlencoded- Custom headers
- Per-call timeout
- External
AbortSignal - Retry + backoff
- Upload / download progress
- Error handling with
ApiError - Paginated list
- Cancel previous request (debounced search)
- Passing metadata to interceptors
- Caching
- Interceptors
- Framework-agnostic core
- Module options reference
RequestOptionsreferenceRetryOptionsreference- Exported types
Install
pnpm add @alikhalilll/nuxt-api-providerRegister 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 },
// Optional paths to modules with default-exported interceptors.
onRequestPath: '~/api/on-request',
onSuccessPath: '~/api/on-success',
onErrorPath: '~/api/on-error',
},
});Usage
Typed client + composable
$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 User {
id: string;
email: string;
}
const api = useApi();
const user = await api<User>('/me');
</script>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 — you'd need to 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>Keep the handler thin — 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 Framework-agnostic core.
GET
interface Post {
id: number;
title: string;
}
const post = await api<Post>('/posts/1');Promise<T | undefined> — you get undefined on 204/205 (no body).
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' });Multipart / FormData upload
FormData is passed through and the Content-Type header is dropped so the browser sets the correct 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 });application/x-www-form-urlencoded
Pass a URLSearchParams instance — Content-Type is set for you if missing.
const body = new URLSearchParams({ grant_type: 'refresh_token', token: rt });
await api<{ access_token: string }>('/oauth/token', { method: 'POST', body });Custom headers
await api('/posts/1', {
headers: { 'X-Trace-Id': crypto.randomUUID() },
});Per-call timeout
// This call aborts after 3s regardless of the client-level default.
await api('/slow', { timeoutMs: 3_000 });External AbortSignal
Your signal is combined with the internal timeout signal via AbortSignal.any (with a polyfill fallback).
const ctrl = new AbortController();
const promise = api('/stream', { signal: ctrl.signal });
// ... later
ctrl.abort();Retry + backoff
Per-client defaults from nuxt.config.ts can be overridden per call:
// Retry up to 3 more times on 503, doubling the delay each time.
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 of a request. 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 (chunked / streaming responses).
<script setup lang="ts">
const uploaded = ref(0);
const uploadRatio = ref(0);
const downloaded = ref(0);
const form = new FormData();
form.append('file', fileInput.value!.files![0]);
await useApi()('/uploads', {
method: 'POST',
body: form,
retry: { attempts: 0 }, // don't silently re-upload on failure
timeoutMs: 60_000,
onRequestProgress: ({ phase, loaded, total, ratio }) => {
if (phase === 'upload') {
uploaded.value = loaded;
if (ratio !== null) uploadRatio.value = ratio;
} else {
downloaded.value = loaded;
}
},
});
</script>
<template>
<progress :value="uploadRatio" max="1" />
<span>{{ uploaded }} bytes</span>
</template>How it works
fetchcannot report upload progress in any browser today, so whenonRequestProgressis set the client transparently swaps its transport toXMLHttpRequest. Everything else — interceptors, retry, timeout, headers, body encoding,AbortSignal,ApiError— continues to work identically.- The fast path (no progress callback) stays on native
fetch; there's no perf cost for requests that don't need progress. - Browser-only: progress callbacks have no meaning on the server and the XHR transport isn't available in Nitro. The module will throw if you try to use it there.
Error handling with ApiError
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 reusable contract for typed error shapes:
import type { IError } from '@alikhalilll/nuxt-api-provider/types';
type LoginError = IError<'email' | 'password', 'hint'>;Discriminate caught errors with isApiError(e) (or the equivalent ApiError.is(e)) instead of instanceof. instanceof breaks when the package is duplicated in a bundle, when errors cross realm boundaries (iframes, workers), or when the class is downleveled to ES5 — isApiError uses a Symbol.for(...) brand and is robust to 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 (unknown)
console.log(e.response); // the Response object, if any
}
}The normalizeErrorPayload helper digs into errors, detail, details, and data.errors, flattening arrays and nested objects into details.errors for you.
Paginated list
async function listPostsPage(page: number) {
return api<Post[]>('/posts', null, { _page: page, _limit: 10 }) ?? [];
}
const all: Post[] = [];
for (let p = 1; ; p++) {
const chunk = await listPostsPage(p);
if (chunk.length === 0) break;
all.push(...chunk);
}Cancel previous request (debounced search)
let current: AbortController | null = null;
async function search(q: string) {
current?.abort();
current = new AbortController();
try {
return await api<Result[]>('/search', { signal: current.signal }, { q });
} catch (e) {
if (isApiError(e) && e.message.includes('abort')) return [];
throw e;
}
}Passing metadata to interceptors
options.meta is copied onto context.meta and is readable in every interceptor.
await api<Post>('/analytics', {
method: 'POST',
body: event,
meta: { feature: 'onboarding', silent: true },
});
api.useError((err, ctx) => {
if (ctx.meta.silent) return; // don't toast silent calls
toast.error(err.message);
});Caching
The client ships with a request cache modelled on TanStack Query and enabled by default. The mental model is staleTime + gcTime:
- An entry is fresh for
staleTimeafter it's stored — reads inside this window return cached data with no network call. - After
staleTime, the entry is stale but still cached forgcTime. Reads return cached data immediately and fire a background refetch (stale-while-revalidate). - After
gcTimeof inactivity, the entry is garbage-collected.
In-flight identical calls are deduplicated — fire 10 GET /me at once and only one network request goes out. Only GET and HEAD are cached; every mutation passes through untouched.
Defaults
| Setting | Default | Meaning |
| ------------------ | ---------------- | ------------------------------------------------------- |
| enabled | true | Cache is on out of the box. |
| staleTime | 30_000 (30s) | Fresh window — pure cache hit, zero network. |
| gcTime | 300_000 (5min) | Stale window — cache hit + background refetch. |
| swr | true | When stale, refresh in the background. |
| cacheableMethods | ['GET','HEAD'] | Mutations are never cached. |
| dedupe | true | Identical concurrent calls share one in-flight promise. |
| maxEntries | 500 | LRU eviction safety net. |
Tune them globally in nuxt.config.ts:
apiProvider: {
baseURL: '...',
cache: {
staleTime: 60_000, // 1 minute fresh window
gcTime: 10 * 60_000, // 10 minute survival
swr: true,
hydrate: true, // forward SSR cache to the client (see below)
},
}Per-call overrides
// Long-fresh: this list barely changes — keep it for 5 minutes.
const countries = await api<Country[]>('/countries', { cache: { staleTime: 5 * 60_000 } });
// Disable the cache for this one call.
const live = await api<Quote>('/stocks/AAPL', { cache: false });
// Cache a POST (you're sure it's idempotent — e.g. a search endpoint).
const results = await api<Hit[]>('/search', {
method: 'POST',
body: { q: 'nuxt' },
cache: { cacheableMethods: ['POST'] },
});Forcing a refetch
// Bypass the fresh window for this call. The response replaces the cached entry.
const fresh = await api<User>('/me', { cache: { refetch: true } });Custom cache keys
By default the key is derived from method + URL + sorted query string + body fingerprint. Supply your own when the auto-derived shape is too narrow (e.g. different query strings should share a cache) or too broad (e.g. you want a per-locale cache that the URL doesn't reflect):
// Locale-scoped cache for the same endpoint.
const intro = await api<Intro>('/intro', {
cache: { key: ['intro', locale.value] },
});Manual invalidation after mutations
The cache exposes a small imperative API. The pattern is the same as TanStack Query — mutate, then invalidate:
// Drop every entry whose key starts with the posts namespace.
await api<Post>('/posts', { method: 'POST', body: draft });
api.cache.invalidate((key) => key.startsWith('GET:') && key.includes('/posts'));
// Or do it from a response interceptor for any mutation that touches /posts.
api.useResponse((ctx) => {
const method = (ctx.request.options.method || 'GET').toUpperCase();
if (method === 'GET' || method === 'HEAD') return;
if (ctx.request.endpoint.startsWith('/posts')) {
api.cache.invalidate((key) => key.includes('/posts'));
}
});
// Nuke everything.
api.cache.clear();Available methods:
| Method | Purpose |
| ------------------------- | -------------------------------------------------------------------- |
| cache.invalidate(pred) | Drop every entry matching the predicate. Returns the number removed. |
| cache.delete(key) | Drop a single entry by its cache key. |
| cache.clear() | Drop every stored entry. In-flight requests keep running. |
| cache.serialize() | Dump the store as a SerializedCache snapshot. |
| cache.hydrate(snapshot) | Restore entries from a snapshot. Expired entries are skipped. |
| cache.entriesIter() | Iterate [key, entry] pairs, ordered by recency (LRU at the front). |
Disabling the cache
// Globally
apiProvider: {
cache: {
enabled: false;
}
}
// Per call
await api('/something', { cache: false });SSR → CSR hydration
By default the SSR cache and the CSR cache are independent. The Nuxt module can wire them together via the Nuxt payload when you opt in:
apiProvider: {
cache: { hydrate: true },
}With hydrate: true:
- On the server, after rendering completes,
client.cache.serialize()is stored inuseState('apiProvider:<name>:cache', ...). - On the client, the state is read back during plugin init and applied via
client.cache.hydrate(snapshot).
Net effect: an SSR fetch for /posts/1 populates the client cache too, so the first client navigation hits the cache instead of refetching. Combine with staleTime to control how long the hydrated entry stays fresh.
Heads up: hydrating the cache adds the cached payload to the SSR response. If your responses are large or contain anything user-specific you don't want serialised into HTML, leave hydration off and let
useAsyncDatahandle the SSR hand-off per key instead.
CacheOptions reference
Per-call options on RequestOptions.cache. May also be false to disable for that call.
| Field | Type | Default | Purpose |
| ------------------ | ------------------------------ | -------------- | ------------------------------------------------------- |
| enabled | boolean | client default | Per-call enable/disable. |
| staleTime | number (ms) | client default | Fresh window override. |
| gcTime | number (ms) | client default | GC horizon override. |
| swr | boolean | client default | Background refetch on stale. |
| cacheableMethods | readonly string[] | client default | Methods eligible for caching. |
| dedupe | boolean | client default | Share in-flight identical calls. |
| maxEntries | number | client default | LRU bound (rarely useful per-call). |
| key | string \| readonly unknown[] | auto-derived | Explicit cache key. Arrays are deeply hashed. |
| refetch | boolean | false | Force the network, replace the cached entry on success. |
CacheConfig reference
Client-level config under ApiClientConfig.cache (or apiProvider.cache in nuxt.config.ts). Same fields as CacheOptions minus key and refetch, which are per-call only. The Nuxt module accepts one extra field — hydrate: boolean — handled by the module itself, not the core client.
Interceptors
There are three kinds of interceptors. Register them via module options (file paths with a default export) or at runtime on the client. Every registration returns an unregister function.
Lifecycle
client(endpoint)
├─ runRequestInterceptors(ctx) ← can mutate ctx or return a new one (chained)
├─ build URL · encode body · fetch
├─ if !response.ok → throw ApiError
├─ retry loop on retryable status / network error
├─ runResponseInterceptors(resCtx) ← can mutate resCtx.data or return a new resCtx (chained)
└─ on thrown ApiError → runErrorInterceptors(err, ctx) ← side-effect onlyskipInterceptors: true on a per-call basis bypasses request, response, and error interceptors for that one call.
Type signatures
// All three live on `@alikhalilll/nuxt-api-provider/types`.
interface RequestContext {
endpoint: string;
baseURL: string;
headers: Record<string, string>;
queries: Record<string, unknown>;
options: RequestOptions; // per-call options (headers cleared — they live on ctx.headers)
meta: Record<string, unknown>;
}
interface ResponseContext<T = unknown> {
request: RequestContext;
response: Response;
data: T | undefined; // already JSON-parsed; mutate or replace
}
type RequestInterceptor = (
ctx: RequestContext
) => void | RequestContext | Promise<void | RequestContext>;
type ResponseInterceptor = <T = unknown>(
ctx: ResponseContext<T>
) => void | ResponseContext<T> | Promise<void | ResponseContext<T>>;
type ErrorInterceptor = (err: ApiError, ctx: RequestContext) => void | Promise<void>;Request and response interceptors chain — the value returned by interceptor N is what interceptor N+1 receives. Returning nothing keeps the previous context. Error interceptors are side-effect only; they can't suppress or rewrite the thrown ApiError.
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;// nuxt.config.ts
apiProvider: {
baseURL: '...',
onRequestPath: '~/api/on-request',
}Response transform / unwrap
Response interceptors are chained the same way as request interceptors. Each one gets the ResponseContext from the previous step and may either mutate it in place or return a new context. Whatever the last interceptor leaves in ctx.data is what the caller awaits.
Common use case — strip a { data: T } envelope so consumers see T directly:
// ~/api/on-success.ts
import type { ResponseInterceptor } from '@alikhalilll/nuxt-api-provider/types';
const unwrapEnvelope: ResponseInterceptor = (ctx) => {
const payload = ctx.data as { data?: unknown } | undefined;
if (payload && typeof payload === 'object' && 'data' in payload) {
ctx.data = payload.data as typeof ctx.data;
}
};
export default unwrapEnvelope;Or return a brand-new context (handy when you'd rather not mutate):
const onSuccess: ResponseInterceptor = (ctx) => ({
...ctx,
data: normalize(ctx.data),
});Pure side-effect interceptors return nothing — useful for tracing, analytics, latency logs:
api.useResponse((ctx) => {
console.debug(ctx.response.status, ctx.response.headers.get('server-timing'));
});The ctx.request field gives you the full RequestContext that produced this response (endpoint, baseURL, headers, queries, options, meta) so you can branch on ctx.request.meta.feature, the URL, or any header you set in the request interceptor.
Error → toast + 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 your interceptor depends on a composable (i18n, toast, router) that isn't available at module-setup time:
// plugins/01-api-interceptors.client.ts
export default defineNuxtPlugin(() => {
const { $apiProvider } = useNuxtApp();
const { locale } = useI18n();
const unregister = $apiProvider.useRequest((ctx) => {
ctx.headers['X-Locale'] = locale.value;
});
// Call `unregister()` to remove this interceptor later.
});Framework-agnostic core
Everything the Nuxt plugin wraps is available as a plain function you can use in Node, Bun, Deno, a CLI, or a test.
Node / Bun / CLI
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}`;
}
});
try {
const repo = await client<{ stargazers_count: number }>('/repos/nuxt/nuxt');
console.log(repo?.stargazers_count);
} catch (e) {
if (isApiError(e)) process.exitCode = 1;
throw e;
}Inside Nitro server routes
// server/api/proxy.ts
import { createApiClient } from '@alikhalilll/nuxt-api-provider/core';
const upstream = createApiClient({
baseURL: 'https://internal.example.com',
headers: { 'X-Internal-Key': process.env.INTERNAL_KEY! },
});
export default defineEventHandler(async (event) => {
const query = getQuery(event);
return upstream('/widgets', null, query);
});ApiClientConfig reference
| Field | Type | Default | Purpose |
| -------------- | --------------------------------- | ------------------ | ---------------------------------------------------------------------- |
| baseURL | string | '' | Prepended to every relative endpoint. |
| timeoutMs | number | 20000 | Default timeout (overridable per call). |
| retry | Partial<RetryOptions> | {} | Default retry policy. |
| headers | HeadersInit | — | Default headers merged into every request. |
| fetch | typeof fetch | globalThis.fetch | Inject a custom fetch (test doubles, polyfills, instrumented fetches). |
| interceptors | { request?, response?, error? } | {} | Initial interceptor arrays — equivalent to calling .useX() later. |
| cache | CacheConfig | defaults | Request-cache configuration. See Caching. |
Initial interceptors at construction
const client = createApiClient({
baseURL: 'https://api.example.com',
interceptors: {
request: [
(ctx) => {
ctx.headers['X-Trace'] = crypto.randomUUID();
},
],
response: [
(ctx) => {
ctx.data = unwrap(ctx.data);
},
],
error: [
(err) => {
logger.error(err);
},
],
},
});Custom fetch (testing / instrumentation)
You almost never need this. Modern Nuxt, browsers, Node 18+, Bun, and Deno all ship a global
fetch, so leavingfetchunset is the right default. It's an escape hatch for environments or use cases the platformfetchcan't cover.
When you'd actually inject one:
- Unit tests that stub the network without an MSW /
nocklayer — return cannedResponseobjects. - Older Node (≤ 17) or sandboxes without a global
fetch— passundici/node-fetch. - CLI or Nitro edge cases that need a custom dispatcher (HTTP proxy, mTLS, custom DNS, cookie jar).
- Transport-layer tracing / metrics that need to live below the interceptor chain.
const fakeFetch: typeof fetch = async () =>
new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
const client = createApiClient({ fetch: fakeFetch });Notes:
- Must match the platform
fetchsignature(input, init) => Promise<Response>. - Bypassed for any call that sets
onRequestProgress— the client switches toXMLHttpRequestfor that single request (the only way to observe upload progress), then the rest of the pipeline (interceptors, retry, error mapping) continues unchanged. - There's no per-call override —
fetchlives onApiClientConfigonly. Use a separatecreateApiClientinstance if you need different transports for different call sites.
/core helpers
The /core entry exports the building blocks the client itself uses. They're stable and useful in tests, custom transports, and adapters.
| Export | Signature / purpose |
| ------------------------- | ------------------------------------------------------------------------------------------------------------- |
| createApiClient | (config?: ApiClientConfig) => ApiProviderClient. The factory. |
| joinUrl | (endpoint, baseURL) => string. Absolute URLs pass through; collapses duplicate slashes. |
| buildQueryString | (params) => string. null/undefined/empty + whitespace strings skipped; arrays repeated. |
| normalizeHeaders | (HeadersInit) => Record<string,string>. Accepts Headers, arrays, plain objects. |
| dropContentType | (headers) => headers. Case-insensitive Content-Type strip — used internally for FormData. |
| encodeBody | (headers, body) => { headers, body }. JSON / FormData / URLSearchParams / passthrough pipeline. |
| shouldOmitBody | (method?) => boolean. true for GET and HEAD. |
| safeParseJson | (Response) => Promise<T \| undefined>. 204/205 → undefined; non-JSON bodies fall through as text. |
| combineSignals | (internal, external?) => AbortSignal. AbortSignal.any with a polyfill fallback. |
| DEFAULT_RETRY | The default RetryOptions constant. |
| resolveRetry | (clientDefaults, perCall) => RetryOptions. Layered merge. |
| shouldRetryStatus | (status, options) => boolean. |
| computeDelay | (attempt, options) => number. delayMs * backoff^n. |
| sleep | (ms, signal?) => Promise<void>. Abortable. |
| createXhrFetch | (onProgress) => fetch. The XHR-backed fetch used for upload progress. Browser-only. |
| normalizeErrorPayload | (input, fallback) => { message, details }. Flattens common server error shapes into ApiErrorDetails. |
| ApiError / isApiError | The error class and its brand-checked guard (works across realms). |
| ApiCache | Framework-agnostic request cache. Construct directly or read via client.cache. See Caching. |
| buildCacheKey | (input) => string. Deterministic key from { method, url, body, override }. |
| DEFAULT_CACHE_CONFIG | The default CacheConfig constant. |
| resolveCacheConfig | (input?) => Required<CacheConfig>. Merge a user config over defaults. |
| isFresh / isExpired | Pure entry-lifecycle helpers — (entry, now?) => boolean. |
Module options reference
| 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. |
| cache | CacheConfig & { hydrate?: boolean } | defaults | Cache config — see Caching. hydrate: true forwards the SSR cache via the Nuxt payload. |
| onRequestPath | string (optional) | — | Path to a module with a default-exported RequestInterceptor. |
| onSuccessPath | string (optional) | — | Path to a module with a default-exported ResponseInterceptor. |
| onErrorPath | string (optional) | — | Path to a module with a default-exported ErrorInterceptor. |
RequestOptions reference
RequestOptions extends the standard RequestInit (so method, credentials, cache, mode, redirect, referrer, keepalive, integrity, etc. all pass through) and adds:
| Field | Type | Default | Purpose |
| ------------------- | -------------------------------------------------------------------------------------------- | -------------- | ------------------------------------------------------------------------------------------------------ |
| body | object · array · FormData · URLSearchParams · Blob · ArrayBuffer · string · null | — | Plain objects / arrays are JSON-encoded; everything else passes through with the right Content-Type. |
| timeoutMs | number | client default | Per-call timeout. Aborts via an internal AbortController. |
| signal | AbortSignal | — | External abort signal. Combined with the timeout signal via AbortSignal.any. |
| retry | Partial<RetryOptions> | client default | Per-call retry override. { attempts: 0 } disables retries. |
| skipInterceptors | boolean | false | Bypass request, response, and error interceptors for this call. |
| meta | Record<string, unknown> | {} | Arbitrary data forwarded to interceptors via ctx.meta. |
| onRequestProgress | (p: RequestProgress) => void | — | Upload + download progress. Switches transport to XMLHttpRequest. |
| cache | CacheOptions \| false | client default | Per-call cache override. false disables caching for this call. See Caching. |
RetryOptions reference
| Field | Type | Default | Purpose |
| --------------------- | ---------- | -------------------------------- | ----------------------------------------------------- |
| attempts | number | 0 | Retry attempts in addition to the initial call. |
| delayMs | number | 300 | Base delay between retries, in ms. |
| backoff | number | 2 | Exponent applied per attempt (delayMs * backoff^n). |
| statusCodes | number[] | [408, 429, 500, 502, 503, 504] | Response status codes that trigger a retry. |
| retryOnNetworkError | boolean | true | Retry on aborted / network failures (no Response). |
Exported types
import type {
ApiProviderClient,
ApiProviderModuleOptions,
ApiClientConfig,
RequestOptions,
RequestContext,
ResponseContext,
RequestInterceptor,
ResponseInterceptor,
ErrorInterceptor,
RetryOptions,
RequestProgress,
ProgressPhase,
ApiError,
ApiErrorDetails,
IError,
isApiError,
// Cache surface
CacheConfig,
CacheOptions,
CacheEntry,
CachePredicate,
SerializedCache,
ApiCache,
} from '@alikhalilll/nuxt-api-provider/types';License
MIT
