npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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.

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 typeApiProviderClient is exported so you can annotate your own wrappers and composables.
  • Caching, on by default — TanStack-Query-style: staleTime 30s, gcTime 5min, stale-while-revalidate, request deduplication. GET/HEAD only — mutations never cached. Per-call overrides and a client.cache API 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 / string pass through with correct Content-Type.
  • Timeouts + abort — per-call timeout plus AbortSignal support via AbortSignal.any with a polyfill fallback.
  • Upload + download progress — a single onRequestProgress hook. The client transparently switches to XMLHttpRequest only when you pass it, since fetch has no upload-progress support.
  • ApiError class — structured errors with .status, .details, .payload, .response, plus an isApiError(e) / ApiError.is(e) guard that works across bundles and realms.
  • Framework-agnostic coreimport { createApiClient } from '@alikhalilll/nuxt-api-provider/core' to use outside Nuxt.

Table of contents

  1. Install
  2. Register the module
  3. Usage
  4. Caching
  5. Interceptors
  6. Framework-agnostic core
  7. Module options reference
  8. RequestOptions reference
  9. RetryOptions reference
  10. Exported types

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 },
    // 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

  • fetch cannot report upload progress in any browser today, so when onRequestProgress is set the client transparently swaps its transport to XMLHttpRequest. 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 staleTime after it's stored — reads inside this window return cached data with no network call.
  • After staleTime, the entry is stale but still cached for gcTime. Reads return cached data immediately and fire a background refetch (stale-while-revalidate).
  • After gcTime of 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 in useState('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 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.

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 only

skipInterceptors: 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 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 / nock layer — return canned Response objects.
  • Older Node (≤ 17) or sandboxes without a global fetch — pass undici / 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 fetch signature (input, init) => Promise<Response>.
  • Bypassed for any call that sets onRequestProgress — the client switches to XMLHttpRequest for 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 overridefetch lives on ApiClientConfig only. Use a separate createApiClient instance 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/205undefined; 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