@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. 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.
Live · GET /posts/1
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:
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.
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
staleTimethe 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.
Live · request cache
No entry cached yet. Click First fetch to populate.
The panel above is wired to the real $apiProvider.cache. The status badge tracks the tracked entry's lifecycle (Fresh → Stale → Expired), the green and amber bars tick down its staleTime and gcTime windows in real time, and Network calls counts actual fetch round-trips so cache hits stay distinguishable from network calls. Try First fetch then Repeat (hit) — the network counter stays put. Burst ×5 fires five identical GETs concurrently and they collapse into one network call (deduplication).
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:
Per-call overrides
Forcing a refetch
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 (different query strings should share a cache) or too broad (per-locale cache that the URL doesn't reflect):
Manual invalidation after mutations
The cache exposes a small imperative API. The pattern is the same as TanStack Query — mutate, then invalidate:
| 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
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:
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.
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 useAsyncData handle 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.
RequestOptions reference
RequestOptions extends the standard RequestInit (so method, credentials, mode, redirect, referrer, keepalive, integrity, etc. all pass through). Two RequestInit fields are overridden:
body— accepts richer shapes (objects, arrays,FormData,URLSearchParams,Blob,ArrayBuffer,string) and encodes them with the rightContent-Type.cache— repurposed for the application-level request cache. The browser's HTTP-cache directive ('default' | 'no-cache' | ...) is no longer set via this field; use theCache-Controlheader instead.
| 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. |
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. |
cache | CacheConfig | defaults | Request-cache configuration. See Caching. |
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). |
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
| 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 | — | 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. |