Nuxt $fetch vs tanstack-fetch
Stack
Nuxt 3 / 4 · tanstack-fetch/vue · @tanstack/vue-query · ssr-forward
Nuxt’s $fetch (ofetch) is ergonomic for Nitro and pages. It still leaves you to invent Query-ready errors, AbortSignal discipline, SSE with Authorization, and a browser-identical client after SSR.
tanstack-fetch plugs into Nuxt via createFetchPlugin, forwards cookies with ssr-forward, and shares options with Vue Query.
This post is the deep dive: architecture, side-by-side code, graphs, migrate path, SSE, tRPC, and when to keep the default stack client.
TL;DR
| If you need… | Prefer |
|---|---|
| SSR cookies + same client in browser | tanstack-fetch |
Vue Query without $fetch unwrap glue | tanstack-fetch |
Name clash with Nuxt useFetch | import as useFetchClient |
| One-off Nitro utility | $fetch is fine |
| SSE + Authorization | useSse |
npm install tanstack-fetch @tanstack/vue-queryMental model
On the server, read useRequestHeaders(['cookie', …]). On the client, ssr-forward skips. One plugin registration covers both.
Contract that matters for Query:
- Success → return the parsed body
- HTTP failure → throw (so
isErrorworks) - Cancel → pass Query’s
signalthrough
Side-by-side
$fetch in a page (glue for Query)
<script setup lang="ts">
const { data } = await useAsyncData('users', () => $fetch<User[]>('/api/users'))
</script>Great for simple pages — weaker when you need shared cache, mutations, refresh-token, and SSE with the same auth.
tanstack-fetch Nuxt plugin
// plugins/tanstack-fetch.ts
import { createFetch } from 'tanstack-fetch'
import { createFetchPlugin } from 'tanstack-fetch/vue'
export default defineNuxtPlugin((nuxtApp) => {
const config = useRuntimeConfig()
const api = createFetch({
baseUrl: config.public.apiBase as string,
source: import.meta.server ? 'ssr' : 'client',
plugins: ['ssr-forward', 'trace', 'retry-idempotent'],
incoming: () => {
if (!import.meta.server) return undefined
const headers = useRequestHeaders(['cookie', 'authorization', 'x-request-id'])
return {
cookie: headers.cookie,
authorization: headers.authorization,
requestId: headers['x-request-id'],
}
},
})
nuxtApp.vueApp.use(createFetchPlugin({ client: api }))
return { provide: { api } }
})<script setup lang="ts">
import { useFetch as useFetchClient } from 'tanstack-fetch/vue'
import { useQuery } from '@tanstack/vue-query'
const api = useFetchClient()
// or: const { $api } = useNuxtApp()
const { data, isPending } = useQuery({
queryKey: ['users'],
queryFn: ({ signal }) => api.get<User[]>('/users', { signal }),
})
</script>Bootstrap / wiring
Example: Nuxt SSR. Pair with a Vue Query Nuxt plugin for dehydrate/hydrate.
Comparison graph: where glue accumulates
Rough relative boilerplate (lower is better) — a map of where teams spend time, not a micro-benchmark.
tanstack-fetchtypical $fetch path
Feature matrix
| Capability | tanstack-fetch | $fetch / ofetch |
|---|---|---|
| SSR cookie forward | ssr-forward | Manual |
| Vue Query fit | Native | Adapt |
| Name-safe composable | useFetchClient | Nuxt useFetch |
| Typed errors | FetchError | ofetch errors |
| Refresh single-flight | Plugin | DIY |
| SSE + Authorization | Yes | No |
| Nitro-only calls | Use either | Native $fetch |
Auth, status handlers, refresh
import { createFetch } from 'tanstack-fetch'
import { createRefreshTokenInterceptor } from 'tanstack-fetch/plugins'
let expiresAt = 0
const api = createFetch({
baseUrl: '/api',
getToken: () => localStorage.getItem('access_token'),
})
api.use(
'refresh-token',
createRefreshTokenInterceptor({
refresh: async () => {
const body = await api.post<{ accessToken: string; expiresIn: number }>('/auth/refresh', {
interceptors: { eject: ['refresh-token', 'auth'] },
})
localStorage.setItem('access_token', body.accessToken)
expiresAt = Date.now() + body.expiresIn * 1000
},
before: { getExpiresAt: () => expiresAt, skewMs: 60_000 },
after: { enabled: true },
}),
)The plugin keeps one in-flight refresh and retries the failed call — the usual interceptor queue, without the queue code.
Mutations
import { useMutation, useQueryClient } from '@tanstack/vue-query'
import { useFetch as useFetchClient } from 'tanstack-fetch/vue'
const api = useFetchClient()
const queryClient = useQueryClient()
useMutation({
mutationFn: (input: CreateUserInput) => api.post<User>('/users', { body: input }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['users'] }),
})Live data: SSE
Browser EventSource cannot set Authorization. tanstack-fetch/sse streams over fetch.
Create the stream client from tanstack-fetch/sse (often in the same Nuxt plugin with sse-resume) and call useSse in a client-only component.
Status: connecting | connected | disconnected | error.
tRPC over the same client
Use createTRPCFetch(api) so Nuxt SSR cookies apply to tRPC batches. See tRPC · Vue/Nuxt.
Migrate
npx tanstack-fetch migrate --from ofetch --framework nuxt --write
npx tanstack-fetch doctor --dir ./Suggested folder layout
plugins/tanstack-fetch.ts
plugins/vue-query.ts
queries/users.ts
pages/users.vue
components/OrdersLive.client.vueDecision tree
- Is TanStack Query your source of truth for server state?
- Yes → start with
tanstack-fetch - No → $fetch may stay
- Yes → start with
- Do you need SSE with Authorization or resume?
- Yes →
tanstack-fetch/sse+useSse - No → either client works for simple REST
- Yes →
- Do other frameworks share this backend client?
- Yes → one
createFetchacross packages - No → still fine; adapters are optional
- Yes → one
Checklist
- [ ] Nuxt plugin with
ssr-forward+incoming - [ ] Import
useFetchasuseFetchClientif needed - [ ] Forward
signalin queryFns - [ ] SSE in client components
- [ ] Doctor clean
Try it
npm install tanstack-fetch @tanstack/vue-query
npx tanstack-fetch doctor --dir ./srcRelated
Series
Same deep-dive format for every stack:
