Nuxt Custom Fetch
nuxt-custom-fetch is a Nuxt 4 wrapper built on top of the official async-data primitives. It keeps a shared request layer with interceptors, deterministic key generation, param/query preprocessing, and a client compatibility fallback for calls made after mount.
For new Nuxt 4 code, start with the official APIs first:
useFetchuseAsyncDatacreateUseFetchcreateUseAsyncData$fetch
Use CustomFetch when you specifically need an extra request layer on top of those primitives, not as a replacement for them.
Compatibility
v4: Nuxt>= 4.5.0v2: Nuxt3.0.0to3.16.x
Current Maintenance Summary
The current implementation is maintained around these guarantees:
- runtime behavior is aligned with Nuxt 4 async-data semantics where possible
- public method generics follow Nuxt
AsyncDatatyping more closely - reactive
key,baseURL,params,query,headers,body, andcachevalues are resolved before each request - generated keys are derived with Nuxt's
hashKey(the same digestuseFetchuses) and include bothparamsandquery, which avoids stale client reuse when only one side changes - same-key client compatibility requests share one async-data bucket, so
dedupe: 'cancel'can abort the previous pending request - the client compatibility path mirrors Nuxt 4 async-data behavior:
pendingfollows thependingWhenIdleconfig,refreshNuxtData()(theapp:data:refreshhook) re-runs fallback requests,getCachedDatais honored and successful data is written back tonuxtApp.payload.data - the client compatibility path honors Nuxt 4.5's
enabledoption (a reactiveenabledturningfalsecancels the in-flight request) and cancels an in-flight request when its owning scope is disposed - timeouts and external abort signals are merged into one request signal (via
AbortSignal.timeout/AbortSignal.anysemantics), key watchers are only created for reactive keys, and a key change re-runs the request whenimmediate, prior data, or an in-flight request is present - Vitest runtime tests and TypeScript type tests cover the wrapper behavior
When To Use This Module
CustomFetch is useful when you want:
- shared request and response interceptors
- deterministic hash-based request keys
- explicit
immutableKeycontrol - param or query preprocessing through
handler - one request API that still works after mount on the client
If all you need is a typed composable with shared defaults, prefer Nuxt's official factories:
export const useAPI = createUseFetch({
baseURL: '/api',
lazy: true
})
export const useCachedData = createUseAsyncData({
deep: false
})
How It Works
- In
setup(including client-side navigation), route middleware, plugins during hydration, and other setup-compatible contexts,CustomFetchdelegates back touseAsyncData, so options such aslazy,server, andenabledfollow Nuxt exactly. - After hydration on the client, calls made outside component setup (event handlers, lifecycle hooks such as
onMounted, watchers) fall back to a compatibility mode. This is the same condition under which Nuxt warns "Component is already mounted". - The compatibility mode still exposes
data,error,status,pending,refresh,execute, andclear. - The compatibility mode is not a full SSR payload or cache replacement.
- Calls that intentionally share the same
keyshould keephandler,deep,transform,pick,getCachedData, anddefaultconsistent, matching Nuxt's keyed async-data rules.
Installation
pnpm add nuxt-custom-fetch
Register the module in nuxt.config.ts:
export default defineNuxtConfig({
modules: ['nuxt-custom-fetch']
})
CustomFetch is auto-imported in Nuxt app code. In plain TypeScript helpers, importing it from #imports keeps editor support explicit.
Quick Start
import { CustomFetch } from '#imports'
export const ajax = new CustomFetch({
baseURL: '/api',
showLogs: true,
handler: input => input,
offline: () => {
console.warn('Device is offline')
}
})
export const getUsers = () =>
ajax.get<{ data: Array<{ id: number, name: string }> }>('/users')
export const createUser = (payload: { name: string }) =>
ajax.post<{ id: number, name: string }>('/users', {
body: payload
})
Always await ajax.get, ajax.post, and ajax.request in setup-compatible code.
Reactive Example
import { computed, ref } from 'vue'
import { CustomFetch } from '#imports'
const ajax = new CustomFetch({ baseURL: '/api' })
const page = ref(1)
const requestKey = computed(() => `list:${page.value}`)
const listState = await ajax.get<{
data: number[]
nums: number
}>('/get-list', {
key: requestKey,
params: { page }
}, {
watch: [page],
dedupe: 'cancel'
})
page.value++
await listState.refresh()
Playground Real Examples
The playground examples now use one consistent set of scenario names and helper names. The shared helper file lives in playground/api/index.ts.
export const getGreeting = (params: Record<string, unknown>) =>
ajax.get<string>('/hello', { params })
export const getGreetingByUserId = (key: MaybeRefOrGetter<string>, { userId = 1 } = {}) =>
ajax.get<string>('/hello', {
key,
params: { userId }
}, {
default: () => '11'
})
export function getReactivePageList (page: Ref<number>) {
return ajax.get<{ data: number[], nums: number }>('/get-list', {
params: { page }
}, {
watch: [() => page.value]
})
}
- Shared Greeting State: playground/pages/example/v4-fetch.vue and playground/components/Test.vue use
getGreetingto show that a page and a child component can share one keyed async-data bucket. - Route-Driven Greeting: playground/pages/example/v4-reactive-[id].vue uses
getGreetingByUserIdwith a reactive key derived from the route. - Reactive Page List: playground/pages/example/reactive.vue uses
getReactivePageListto show page-based refetching driven by a reactive page ref. - Query Normalization: playground/pages/example/handler.vue shows how a
handlercan normalize and enrich list query parameters before the request is sent. - Slow Metric Dedupe: playground/pages/example/duplicate.vue demonstrates same-key cancellation for repeated slow client requests with
dedupe: 'cancel'. - Manual Refresh & Clear: playground/pages/example/test.vue uses
getGreetingto comparerefresh()andclear()across two independent keyed requests.
The remaining helper names follow the same rule: getPageList is the plain non-reactive list request, and getDelayedPageMetric is the intentionally slow metric request used for dedupe demonstrations.
Upgrading From 4.4.x
- Nuxt
>= 4.5.0is required. - Calls made in
setupduring client-side navigation now go throughuseAsyncData, solazy: trueno longer blocks navigation andserver/enabledfollow Nuxt's rules. Calls from event handlers,onMounted, or watchers still use the compatibility mode. - Errors thrown by
useAsyncDataare no longer swallowed and turned into a fallback. - Requests are sent with
queryonly;paramsis merged into it. Interceptors still seeoptions.params, which ofetch mirrors fromquery. - Generated keys changed because they now use
hashKey. Only code that hard-coded a generated key (for example inuseNuxtData) needs updating. erroris typed asNuxtError<unknown> | undefinedby default.
Request Semantics
Key generation and dedupe
- Without an explicit
key, the module hashesurl + method + resolved request optionswith Nuxt'shashKey. - Both
paramsandqueryparticipate in the generated key (they are merged intoquerybefore hashing). FormDatabodies are hashed by their ordered entries (files asname:size:lastModified) andURLSearchParamsbodies by their entries, so different uploads never share a key.immutableKey: truemakes the generated key depend only on the URL.- If you need exact cache control, provide your own
key. - Same key means shared async-data state.
dedupe: 'cancel'aborts the previous in-flight request inside that shared state.dedupe: 'defer'reuses the existing pending request.
Reactive inputs
key,baseURL,params,query,headers,body, andcachecan be refs, computed values, or getters.- Reactive inputs are deeply resolved before every request.
- Use
watchwhen you want a reactive source to trigger a refetch.
Handler behavior
handlerreceives a merged object built fromparamsandquery.- The processed output is always sent as
query.paramsis ofetch's deprecated alias ofquery, so it is only read as input and never forwarded on its own. - Set
useHandler: falseon a request to bypass preprocessing; the merged object is still sent asquery.
Client compatibility mode
- Client calls made after mount reuse existing same-key async-data state when available, and still get the full
AsyncDatashape (refresh,execute, andclearincluded). - If no Nuxt-managed keyed state exists yet, the module creates and caches a compatibility async-data instance by key.
refresh,execute,clear,watch, status updates, and cancellation still work in this mode.- This mode should not be treated as a full SSR payload cache replacement.
Public API
const ajax = new CustomFetch({
baseURL: '',
immutableKey: false,
showLogs: import.meta.dev,
useHandler: true,
handler: undefined,
onRequest: undefined,
onRequestError: undefined,
onResponse: undefined,
onResponseError: undefined,
offline: undefined
})
Available methods:
ajax.get(url, config?, asyncDataOptions?)ajax.post(url, config?, asyncDataOptions?)ajax.request(url, { method, ...config }, asyncDataOptions?)
The return value follows Nuxt's AsyncData<...> shape and can use the same default, pick, transform, watch, dedupe, timeout, and enabled options you already know from useAsyncData.
showLogs defaults to true in development and prints each client request with console.info (real problems such as an unserializable body still use console.warn). Set showLogs: false to silence it.
Typing Notes
CustomFetchmirrors Nuxt async-data generics closely enough fordefault,pick, andtransformto narrow the finaldatatype.errordefaults toNuxtError<unknown> | undefined, likeuseAsyncData; the second generic (NuxtErrorDataT) typeserror.data.- Playground examples and type tests cover explicit generics, default values, and reactive arguments.
- If you want a custom wrapper with only shared defaults, Nuxt's
createUseFetchandcreateUseAsyncDataremain the simpler choice.
Nuxt 4 API Reminders
useFetchis the official shortcut foruseAsyncData + $fetch.$fetchalone inside SSR setup will fetch twice during hydration unless it is wrapped byuseAsyncDataor replaced withuseFetch.- Relative
useFetchcalls on the server forward headers and cookies automatically. Plain$fetchdoes not. dedupe,timeout,watch, reactive keys, andAbortSignalare part of Nuxt's async-data contract.
Development
pnpm dev:preparebuilds stubs and prepares the playgroundpnpm devstarts the playgroundpnpm dev:buildbuilds the playgroundpnpm testruns the Vitest suitepnpm run test:coveragegenerates a coverage reportpnpm run test:typesruns TypeScript type checks