@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
$pnpmadd@alikhalilll/nuxt-api-providerRegister the module
Basic usage
$apiProvider is augmented onto NuxtApp and ComponentCustomProperties as an ApiProviderClient. Wrap it in a composable to stop destructuring it everywhere:
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.
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 …".
If you factor the call into a service, pass the client in rather than calling useNuxtApp() inside the service:
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.
nullandundefinedare skipped.- Empty and whitespace-only strings (
'',' ') are skipped. - Arrays are repeated:
{ tag: ['a','b'] }→?tag=a&tag=b.null/undefined/ empty entries inside arrays are skipped too. - Anything else is coerced via
String(...), so numbers and booleans are fine.
POST with JSON
Plain objects (and arrays) are JSON-encoded; Content-Type: application/json is set automatically.
PATCH / PUT / DELETE
FormData / multipart
FormData is passed through and Content-Type is dropped so the browser sets the boundary.
URL-encoded
URLSearchParams sets Content-Type: application/x-www-form-urlencoded when not already present.
Timeouts and abort
Retry and backoff
Per-client defaults from nuxt.config.ts can be overridden per call.
Delay for attempt n (0-indexed) is delayMs * backoff^n. Retries fire on either a network error (status === 0, controllable via retryOnNetworkError) or a response whose status appears in statusCodes.
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). |
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.
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:
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.
The body parser falls through gracefully: 204 / 205 give undefined, valid JSON gives the parsed object, and non-JSON bodies are returned as the raw text (so payload can be string). This applies to both successful responses and error payloads.
normalizeErrorPayload (re-exported from /core) is what turns server payloads into the { message, details } shape attached to the error. It dives into common envelopes — errors, detail, details, and data.errors — flattening arrays and nested records into details.errors.
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. |
Cancel previous request (debounced search)
Paginated list
Interceptors
There are three kinds: request, response, and error. Register them via module options (file paths with a default export) or at runtime via client.useRequest / client.useResponse / client.useError. Every registration returns an unregister function.
Lifecycle
skipInterceptors: true on a per-call basis bypasses request, response, and error interceptors for that single call.
Type signatures
All three are exported from @alikhalilll/nuxt-api-provider/types.
Request and response interceptors chain — what interceptor N returns 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.
Request — auth header
Response — transform / unwrap
Response interceptors chain the same way as request interceptors. Mutate ctx.data in place, or return a new context — whatever the last interceptor leaves in ctx.data is what the caller awaits.
Strip a { data: T } envelope so callers see T directly:
Or return a new context instead of mutating:
Pure side-effect (tracing, analytics, latency logging) — return nothing:
ctx.request 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 anything you set in the request interceptor.
Error — redirect / toast
Error interceptors run after retries are exhausted. They cannot suppress the throw — re-raise from the call site if you need recovery.
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.
Passing metadata
options.meta is copied onto ctx.meta and is readable in every interceptor — handy for opting individual calls out of generic behavior:
Skipping interceptors
Framework-agnostic core
Everything the Nuxt plugin wraps is available as a plain factory. Works in Node, Bun, Deno, a CLI, or a test.
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. |
Initial interceptors at construction
Custom fetch (testing / instrumentation)
You almost never need this. Modern Nuxt, browsers, Node 18+, Bun, and Deno all ship a global fetch, so leaving fetch unset is the right default. It's an escape hatch for environments or use cases the platform fetch can'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.
Notes:
- The function 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.
Inside Nitro server routes
/core helpers
The /core entry exports the small 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. Same skip-rules as the third call argument. |
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 }. The body-encoding pipeline (JSON / FormData / URLSearchParams / passthrough). |
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). |
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. |