@alikhalilll/nuxt-api-provider

Strongly-typed fetch client for Nuxt 3/4 with interceptors, retry/backoff, and a framework-agnostic core.
2,510 4
Install
npm install @alikhalilll/nuxt-api-provider
Latest Version:2.0.0
Vue:3
License:MIT
Last Updated:Oct 5, 2026
Links: GitHub  ·  npm
Maintainer: alikhalilll_dev

@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 — ApiProviderClient 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 core — import { 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 override — fetch 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/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

Related Packages

@x-app/nuxt-api

Configurable API composable for Nuxt 3 and Nuxt 4

393
nuxt-openapi-hyperfetch

Nuxt module for generating an OpenAPI SDK, composables, server routes, and headl...

4,669
nuxt-api-party

Auto-generated composables for any API, typed from OpenAPI and never exposing yo...

246,965 306
@sampullman/vue3-fetch-api

Small fetch wrapper for quickly prototyping API clients

1,178 4
nuxt-open-fetch

Generate zero-overhead, 100% typed OpenAPI clients for Nuxt.

379,588 259

Version History

Version Released Vue License
2.0.0 ^3.0 MIT
1.3.0 ^3.0 MIT
1.2.0 ^3.0 MIT
1.1.2 ^3.0 MIT
1.1.1 ^3.0 MIT