nuxt-fetch-factory
v0.1.2
Published
Nuxt module providing defineFetch and defineAsyncData factory composables with per-call-site key isolation, interceptor composition, and context restoration after await.
Downloads
194
Maintainers
Readme
nuxt-fetch-factory
A Nuxt module that provides defineFetch and defineAsyncData factory
composables. It extends Nuxt's data-fetching layer with two capabilities:
Per-call-site key isolation — Nuxt's scanner assigns unique payload keys to bare
useFetch/useAsyncDatacall-sites, but cannot see through custom wrapper composables. Every component that calls the same wrapper shares a single key, so one component's cached payload leaks into another. This module replicates the same analysis for factory-created composables, injecting a unique key per call-site. On by default; disable per factory withautoKey: falsewhen shared caching is intentional. → nuxt/nuxt#14736Context restoration after
await— all composables returned by this module run insidenuxtApp.runWithContext(), so they work correctly even when called after anawaitin a parent composable. → nuxt/nuxt#33736
How it works
1. Per-call-site key isolation — nuxt/nuxt#14736
Nuxt intentionally assigns a single deduplication key to each useFetch /
useAsyncData call-site so that multiple components fetching the same resource
share one request and one SSR payload — a deliberate performance optimisation.
It does this by statically scanning your source files for the bare useFetch
and useAsyncData symbols. That works perfectly for direct calls, but the
scanner cannot see through a custom wrapper composable — so every call-site of
that wrapper shares the single key generated from the wrapper's own source
location, regardless of which component is calling it or what URL is passed:
// composables/useApi.ts
export const useApi = (url: string) => useFetch(url)
// └─ one call-site → one key for all usages// PageA.vue — fetches /users, stores result under key 'useApi_abc'
const { data: users } = await useApi('/users')
// PageB.vue — key 'useApi_abc' is already populated from PageA's payload;
// Nuxt returns the cached /users data instead of fetching /posts
const { data: posts } = await useApi('/posts') // ← gets users, not postsThis module ships a Vite transform plugin that performs the same analysis for
composables created with defineFetch / defineAsyncData. At build time it
injects a unique _autoKey into every call-site so each gets its own isolated
payload slot, exactly as if it were a direct useFetch call:
// PageA.vue
const { data: users } = await useApi('/users', { _autoKey: 'useApi_a1b2c3d4' })
// PageB.vue — different key, independent payload slot
const { data: posts } = await useApi('/posts', { _autoKey: 'useApi_e5f6a7b8' })The key is derived from <composableName>::<relativeFilePath>::<callIndex> and
is stable across rebuilds, so SSR hydration works correctly.
This behaviour is on by default and can be turned off per factory with
autoKey: false — useful when you intentionally want all components to share
one cached result (e.g. a global useCurrentUser singleton). See the
autoKey option in the API reference.
2. Nuxt context lost after await — nuxt/nuxt#33736
Vue sets a global "current instance" pointer at the start of setup(). Any
await suspends the component and Vue clears that pointer. Composables that
call useNuxtApp(), useCookie(), useState(), or useFetch() internally
will throw when invoked after an await in a parent composable:
A composable that requires access to the Nuxt instance was called outside
of a plugin, Nuxt hook, Nuxt middleware, or Vue setup function.All composables returned by this module call nuxtApp.runWithContext() around
their inner useFetch / useAsyncData invocation, re-establishing the context
transparently. The withContext and withContextAsync utilities extend this
protection to any composable you write yourself.
See Vue: composable usage restrictions
and the runWithContext API docs.
Installation
npm install nuxt-fetch-factory// nuxt.config.ts
export default defineNuxtConfig({
modules: ['nuxt-fetch-factory'],
})Quick start
Service-layer pattern
The recommended approach is to create a factory/ directory where each file
represents a service domain. Each file creates a private HTTP client with
defineFetch and exports named composables for specific endpoints.
src/
factory/
userService.ts → useGetProfile, useGetPosts (auto-imported)
externalService.ts→ useGetFeed (auto-imported)
composables/
useProfilePage.ts → useProfilePage (auto-imported, Nuxt built-in dir)
pages/
index.vueThe factory file exports are auto-imported exactly like composables/ exports.
The naming convention for exported composables follows the standard use prefix
to stay consistent with Nuxt's built-in composables and avoid naming conflicts.
Set a default baseURL (optional)
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['nuxt-fetch-factory'],
fetchFactory: { baseURL: '/api' },
})Any factory that does not set its own baseURL inherits this value.
Override it per-factory for external APIs.
Define a service file
// factory/userService.ts
//
// Private API client — not exported, used only within this file.
// `baseURL` is inherited from nuxt.config.ts fetchFactory.baseURL ('/api').
const api = defineFetch({
onRequest({ options }) {
const token = useCookie('auth_token')
if (token.value)
options.headers = { ...options.headers, Authorization: `Bearer ${token.value}` }
},
onResponseError({ response }) {
if (response.status === 401) navigateTo('/login')
},
})
// Each api() call is a distinct call site — the Vite transform injects
// a different _autoKey into each, giving them isolated payload slots.
export const useGetProfile = () => api('/me')
export const useGetPosts = () => api('/posts')// factory/externalService.ts
//
// Overrides baseURL to point at a third-party origin.
const api = defineFetch({
baseURL: 'https://api.example.com',
onRequest({ options }) {
const { public: { apiKey } } = useRuntimeConfig()
options.headers = { ...options.headers, 'X-API-Key': apiKey }
},
})
export const useGetFeed = (limit = 10) => api('/feed', { query: { limit } })Use the composables in a page
// pages/index.vue — useGetProfile, useGetPosts, useGetFeed are auto-imported
const { data: profile } = await useGetProfile()
const { data: posts } = await useGetPosts()
const { data: feed } = await useGetFeed(5)
// All three have different _autoKey values — isolated payload slots.API reference
defineFetch(defaults)
Creates a useFetch composable with pre-configured defaults.
Factory-level options accept everything useFetch accepts except
default, transform, and pick (which are per-call concerns). The four
lifecycle interceptors (onRequest, onRequestError, onResponse,
onResponseError) compose with call-site interceptors — both run, factory
first.
Generic type parameters:
| Parameter | Default | Description |
|---|---|---|
| DefaultDataT | unknown | Default response type for all calls via this factory |
| — | — | Call-site generics below |
| ResT | DefaultDataT | Raw response type for a single call |
| TransformT | ResT | Output type after applying transform at the call site |
autoKey — opting out of per-call-site key isolation:
By default every composable produced by defineFetch gets a unique
_autoKey injected per call-site, giving each component its own isolated
payload slot (the fix for #14736).
Set autoKey: false when you want all call-sites to share one payload — for
example a global singleton composable where every component should read from the
same cache:
// factory/authService.ts
const api = defineFetch({ baseURL: '/api', autoKey: false })
// Every component calling useCurrentUser() reads the same cached slot.
// PageA, PageB, and the header component all share one fetch.
export const useCurrentUser = () => api('/me')When autoKey: false, Nuxt falls back to its standard key strategy (URL +
options hash), so cross-component deduplication still works as expected.
baseURL resolution order (lowest to highest priority):
- Module option
fetchFactory.baseURLinnuxt.config.ts baseURLin the factory's owndefineFetch({ baseURL })optionsbaseURLpassed at the call site
// Set the default response type at the factory level
const api = defineFetch<User[]>({ baseURL: '/api/users' })
const { data } = await api('/') // data: Ref<User[] | null | undefined>
const { data } = await api('/me') // same inferred typeOverride the response type per call:
const api = defineFetch({ baseURL: '/api' })
const { data: users } = await api<User[]>('/users') // Ref<User[] | null | undefined>
const { data: profile } = await api<UserProfile>('/me') // Ref<UserProfile | null | undefined>Transform — TypeScript tracks the output type through the second generic:
const api = defineFetch<User[]>({ baseURL: '/api' })
// <raw response, transform output>
const { data: names } = await api<User[], string[]>('/users', {
transform: (users) => users.map(u => u.name),
})
// names: Ref<string[] | null | undefined> ← transform output, not User[]
// TransformT is also inferred automatically when you annotate the input:
const { data: ids } = await api('/users', {
transform: (users: User[]) => users.map(u => u.id),
})
// ids: Ref<number[] | null | undefined>Call-site usage is identical to useFetch:
const { data, pending, error, refresh } = await useGetProfile({
query: { include: 'posts' },
lazy: true,
})See Nuxt: useFetch options reference
and ofetch interceptor docs.
defineAsyncData(defaults)
Creates a useAsyncData composable with pre-configured defaults. Useful when
you control the fetch handler at the call site and want to co-locate the default
value and SSR options with the resource type.
Generic type parameters:
| Parameter | Default | Description |
|---|---|---|
| DefaultDataT | unknown | Default response type for the factory |
| DefaultTransformT | DefaultDataT | Default transform output type |
| — | — | Call-site generics below |
| ResT | DefaultDataT | Raw handler return type |
| TransformT | ResT | Output type after applying transform |
// factory/listingsService.ts
// Set DefaultDataT so every composable using this factory is fully typed.
const asyncData = defineAsyncData<{ items: Listing[]; total: number }>({
server: true,
lazy: false,
default: () => ({ items: [], total: 0 }),
})
// Each call to asyncData() is a distinct call site.
// The Vite transform injects a different _autoKey into each.
export const useListingsAll = () =>
asyncData('all', () => $fetch('/api/listings'))
export const useListingsByCategory = (category: string) =>
asyncData(category, () => $fetch('/api/listings', { query: { category } }))autoKey: false — shared payload across all call-sites:
// All components calling useListingsAll() share one cached result.
const asyncData = defineAsyncData({ server: true, autoKey: false })
export const useListingsAll = () =>
asyncData('listings-all', () => $fetch('/api/listings'))Call-site usage mirrors useAsyncData(key, handler, options?). The
_autoKey is prepended to the user-supplied key (when autoKey: true):
useListingsAll() in PageA → "asyncData_<hash-A>:all"
useListingsByCategory('books') → "asyncData_<hash-B>:books"Transform with typed output at the call site:
// TransformT inferred from the transform return type
const { data: titles } = await asyncData(
'titles',
() => $fetch<Listing[]>('/api/listings'),
{ transform: (res) => res.items.map(item => item.title) },
)
// data: Ref<string[] | null> ← transform outputSee Nuxt: useAsyncData reference.
withContext(composable)
Wraps a composable factory so that the Nuxt context survives any await inside
it. Directly addresses the pattern discussed in
nuxt/nuxt#33736.
// composables/useListings.ts
export const useListings = withContext(async (filters: ListingFilters = {}) => {
// First fetch — context alive on first call, no wrapper needed here alone
const { data: listings, pending } = await useApi('/listings', { query: filters })
// Second fetch — would throw without withContext because the first await
// cleared the Vue instance pointer. withContext ensures it still works.
const { data: meta } = await useApi('/listings/meta')
return { listings, meta, pending }
})// pages/listings.vue
const { listings, meta, pending } = await useListings({ category: 'books' })When to use withContext vs withContextAsync:
withContext works with both sync and async inner functions. withContextAsync
is identical in behaviour but is explicitly typed as returning Promise —
use it when you need async/await syntax at the wrapper level for
try/catch blocks.
withContextAsync(composable)
Async-typed variant of withContext. The entire async function runs inside
runWithContext, so every .then() continuation in the microtask chain is
also scheduled within the restored context.
export const useUserProfile = withContextAsync(async (userId: string) => {
const { data: user } = await useFetch(`/api/users/${userId}`)
const { data: posts } = await useFetch(`/api/users/${userId}/posts`)
const { data: follows } = await useFetch(`/api/users/${userId}/follows`)
return { user, posts, follows }
})Configuration
All options are optional. Defaults cover the most common setup.
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['nuxt-fetch-factory'],
fetchFactory: {
// Default baseURL for all defineFetch factories that do not set their own.
// Lowest-priority: factory-level and call-site baseURL always win.
baseURL: '/api',
// Scan src/factory/ and auto-import all its exports.
// Change this if your service files live elsewhere.
factoryDir: 'factory',
// Inject unique _autoKey into every factory composable call-site.
// Disable only if you manage keys entirely by hand.
autoKey: true,
// Auto-import defineFetch, defineAsyncData, withContext,
// withContextAsync without explicit import statements.
autoImport: true,
// Register additional factory composable names for autoKey injection.
// Needed only for factories that live outside factoryDir (e.g. composables/).
factoryNames: [],
},
})| Option | Type | Default | Description |
|---|---|---|---|
| baseURL | string | '' | Default baseURL inherited by all defineFetch factories |
| factoryDir | string | 'factory' | Directory relative to srcDir whose exports are auto-imported |
| autoKey | boolean | true | Inject unique _autoKey per call-site at build time |
| autoImport | boolean | true | Auto-import module utilities without explicit imports |
| factoryNames | string[] | [] | Extra composable names to track for autoKey injection |
How auto-key injection works
The Vite transform plugin (enforce: 'post') runs two passes on every
JS/TS/Vue file:
Phase 1 — Discovery: finds const X = defineFetch(...) and
const X = defineAsyncData(...) declarations and registers X as a
tracked name. The factoryDir is also pre-scanned in buildStart so that
call-site files are keyed correctly even when Vite processes them before
resolving the factory declaration files.
Phase 2 — Injection: for every call to a tracked name, computes
SHA-1(composableName::relativeFilePath::callIndex) truncated to 8 hex
characters and injects _autoKey: "<name>_<hash>" into the options object, or
appends { _autoKey: "..." } as a trailing argument when no options object is
present.
The injection is idempotent — a call that already contains _autoKey is left
unchanged on subsequent builds. Source maps are preserved via
magic-string.
Naming conventions
Exported composables from factory files should follow the standard use prefix
convention. This keeps them consistent with Nuxt's built-in composables and
prevents accidental shadowing in the auto-import registry.
// factory/userService.ts
// Private API client — not exported, no naming conflict possible
const api = defineFetch({ ... })
// Exported composables — use prefix avoids conflicts with Nuxt built-ins
export const useGetProfile = () => api('/me')
export const useGetUserPosts = (id: string) => api(`/users/${id}/posts`)Avoid exporting the raw factory instance directly — it looks like a plain function rather than a composable and bypasses
the use prefix convention expected by Nuxt's auto-import scanner.
Using defineFetch outside the factory directory
If you prefer the composables/ convention, place your factory file directly
there. Nuxt auto-imports its exports and the autoKey transform still applies:
// composables/useSearchService.ts
const api = defineFetch({ baseURL: '/api/search' })
export const useSearch = (query: string) => api('/', { query: { q: query } })
export const useSearchSuggestions = (query: string) =>
api('/suggestions', { query: { q: query } })
// pages/index.vue — useSearch is auto-imported by Nuxt
const { data } = await useSearch('nuxt')Development
This module uses @nuxt/module-builder
for building and packaging.
# Install dependencies
npm install
# Build module stubs and generate playground types.
# Run this once after install, and again whenever src/ changes.
npm run dev:prepare
# Start the playground dev server
npm run dev
# Run tests
npm test
# Build the module for distribution (runs TypeScript compilation internally)
npm run buildHow stub mode works (--stub): nuxt-module-build build --stub generates
lightweight .mjs files in dist/ that re-export directly from the src/
sources. This means you can edit source files and see changes in the playground
immediately without a full rebuild. The dist/ stubs are replaced by the real
compiled output when you run npm run build.
Playground type checking: The playground's tsconfig.json extends
.nuxt/tsconfig.json, which is generated by nuxi prepare as part of
dev:prepare. This makes all Nuxt auto-imports, module augmentations, and
generated types available to the IDE. Run npm run dev:prepare again after
adding or renaming files in factory/ to refresh the auto-import declarations.
Module type checking: The root tsconfig.json covers only src/ and
test/. Run npm run build to invoke the full TypeScript compiler pass that
nuxt-module-build runs during packaging. This is the authoritative type check
for the module's public API.
Background and prior art
This module implements the factory composable pattern proposed by @pi0 in
nuxt/nuxt#14736 and applies the
runWithContext fix discussed in
nuxt/nuxt#33736.
The Nuxt documentation covers a simpler version of the custom useFetch
pattern at
nuxt.com/docs/guide/recipes/custom-usefetch.
This module extends that pattern with automatic key generation, interceptor
composition, and context restoration utilities.
License
MIT
