Vue ofetch / axios vs tanstack-fetch
Stack
Vue 3.3+ · tanstack-fetch/vue · @tanstack/vue-query
ofetch and axios are fine transporters. They are not shaped for Vue Query’s queryFn contract: return data, throw on failure, honor AbortSignal.
tanstack-fetch gives Vue the same client as React — via createFetchPlugin + useFetch composables.
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 |
|---|---|
Drop into useQuery / useMutation | tanstack-fetch |
Typed FetchError | tanstack-fetch |
SSE + Authorization | useSse from tanstack-fetch/vue |
| Simple script without Query | ofetch is fine |
| Share client with React/Nuxt | tanstack-fetch |
npm install tanstack-fetch vue @tanstack/vue-queryMental model
ofetch returns the body on success but error shapes differ from Query’s expectations. axios still wants .data. One client should do both REST and SSE.
Contract that matters for Query:
- Success → return the parsed body
- HTTP failure → throw (so
isErrorworks) - Cancel → pass Query’s
signalthrough
Side-by-side
axios / ofetch + Query (glue)
import axios from 'axios'
import { useQuery } from '@tanstack/vue-query'
const { data, error, isPending } = useQuery({
queryKey: ['users'],
queryFn: async ({ signal }) => {
const res = await axios.get<User[]>('/users', { signal })
return res.data
},
})tanstack-fetch + Vue Query
import { createApp } from 'vue'
import { createFetch } from 'tanstack-fetch'
import { createFetchPlugin, useFetch } from 'tanstack-fetch/vue'
import { VueQueryPlugin, useQuery } from '@tanstack/vue-query'
import { isFetchError } from 'tanstack-fetch'
import App from './App.vue'
const api = createFetch({
baseUrl: import.meta.env.VITE_API_URL,
getToken: () => localStorage.getItem('access_token'),
})
createApp(App)
.use(createFetchPlugin({ client: api }))
.use(VueQueryPlugin)
.mount('#app')<script setup lang="ts">
import { useFetch } from 'tanstack-fetch/vue'
import { useQuery } from '@tanstack/vue-query'
import { isFetchError } from 'tanstack-fetch'
const api = useFetch()
const { data, error, isPending } = useQuery({
queryKey: ['users'],
queryFn: ({ signal }) => api.get<User[]>('/users', { signal }),
})
</script>
<template>
<p v-if="isPending">Loading…</p>
<p v-else-if="isFetchError(error)">{{ error.status }}</p>
<ul v-else>
<li v-for="u in data" :key="u.id">{{ u.name }}</li>
</ul>
</template>Bootstrap / wiring
Pass createFetch options straight into createFetchPlugin({ baseUrl, getToken, … }) if you do not want a separate api module.
In Nuxt, rename when useFetch clashes: import { useFetch as useFetchClient } from 'tanstack-fetch/vue'.
Comparison graph: where glue accumulates
Rough relative boilerplate (lower is better) — a map of where teams spend time, not a micro-benchmark.
tanstack-fetchtypical ofetch / axios path
Feature matrix
| Capability | tanstack-fetch | ofetch / axios |
|---|---|---|
| Returns body for Query | Yes | Adapt |
| Typed error + status | FetchError | Partial |
| AbortSignal | First-class | Yes / vary |
| Status handlers | Built-in | Custom |
| Refresh single-flight | Plugin | Custom |
Path params :id | Yes | Manual |
| SSE + Authorization | Yes | No |
| Vue composables | useFetch / useSse | DIY |
| Bundle | ~4.8KB gzip | Small–medium |
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 } from 'tanstack-fetch/vue'
const api = useFetch()
const queryClient = useQueryClient()
const createUser = 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.
<script setup lang="ts">
import { createFetch } from 'tanstack-fetch/sse'
import { useSse } from 'tanstack-fetch/vue'
const api = createFetch({ plugins: ['sse-resume'] })
const { data, status, error, close } = useSse<OrderEvent>('/orders/stream', { client: api })
</script>
<template>
<p v-if="status === 'error'">{{ error?.message }}</p>
<div v-else>
<p>{{ status }}</p>
<pre>{{ data }}</pre>
<button type="button" @click="close">Stop</button>
</div>
</template>Refs unwrap in the template. Plugin is optional when you pass { client }.
Status: connecting | connected | disconnected | error.
tRPC over the same client
import { createTRPCClient, httpBatchLink } from '@trpc/client'
import { createTRPCFetch } from 'tanstack-fetch/trpc'
import { useQuery } from '@tanstack/vue-query'
import { api } from './api'
import type { AppRouter } from './server'
export const trpc = createTRPCClient<AppRouter>({
links: [httpBatchLink({ url: '/api/trpc', fetch: createTRPCFetch(api) })],
})
const posts = useQuery({
queryKey: ['posts'],
queryFn: ({ signal }) => trpc.post.list.query(undefined, { signal }),
})Migrate
npx tanstack-fetch migrate --from ofetch --framework vue --write
# or
npx tanstack-fetch migrate --from axios --framework vue --write
npx tanstack-fetch doctor --dir ./srcSuggested folder layout
src/
main.ts # createFetchPlugin + VueQueryPlugin
lib/api.ts
queries/users.ts
components/UsersList.vue
components/OrdersLive.vueDecision tree
- Is TanStack Query your source of truth for server state?
- Yes → start with
tanstack-fetch - No → ofetch/axios 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
- [ ]
createFetchPluginregistered - [ ] Forward
{ signal }in every queryFn - [ ]
isFetchErrorfor status UI - [ ] Refresh plugin instead of ad-hoc interceptors
- [ ] SSE via
tanstack-fetch/sse - [ ] Doctor clean
Try it
npm install tanstack-fetch vue @tanstack/vue-query
npx tanstack-fetch doctor --dir ./srcRelated
Series
Same deep-dive format for every stack:
