Skip to content

Nuxt $fetch vs tanstack-fetch ​

October 12, 2026 · Nuxt · Vue · SSR

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 browsertanstack-fetch
Vue Query without $fetch unwrap gluetanstack-fetch
Name clash with Nuxt useFetchimport as useFetchClient
One-off Nitro utility$fetch is fine
SSE + AuthorizationuseSse
bash
npm install tanstack-fetch @tanstack/vue-query

Mental model ​

On the server, read useRequestHeaders(['cookie', …]). On the client, ssr-forward skips. One plugin registration covers both.

Contract that matters for Query:

  1. Success → return the parsed body
  2. HTTP failure → throw (so isError works)
  3. Cancel → pass Query’s signal through

Side-by-side ​

$fetch in a page (glue for Query) ​

vue
<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 ​

ts
// 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 } }
})
vue
<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.

Feature matrix ​

Capabilitytanstack-fetch$fetch / ofetch
SSR cookie forwardssr-forwardManual
Vue Query fitNativeAdapt
Name-safe composableuseFetchClientNuxt useFetch
Typed errorsFetchErrorofetch errors
Refresh single-flightPluginDIY
SSE + AuthorizationYesNo
Nitro-only callsUse eitherNative $fetch

Auth, status handlers, refresh ​

ts
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 ​

ts
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 ​

bash
npx tanstack-fetch migrate --from ofetch --framework nuxt --write
npx tanstack-fetch doctor --dir ./

Suggested folder layout ​

text
plugins/tanstack-fetch.ts
plugins/vue-query.ts
queries/users.ts
pages/users.vue
components/OrdersLive.client.vue

Decision tree ​

  1. Is TanStack Query your source of truth for server state?
    • Yes → start with tanstack-fetch
    • No → $fetch may stay
  2. Do you need SSE with Authorization or resume?
    • Yes → tanstack-fetch/sse + useSse
    • No → either client works for simple REST
  3. Do other frameworks share this backend client?
    • Yes → one createFetch across packages
    • No → still fine; adapters are optional

Checklist ​

  • [ ] Nuxt plugin with ssr-forward + incoming
  • [ ] Import useFetch as useFetchClient if needed
  • [ ] Forward signal in queryFns
  • [ ] SSE in client components
  • [ ] Doctor clean

Try it ​

bash
npm install tanstack-fetch @tanstack/vue-query
npx tanstack-fetch doctor --dir ./src

Series ​

Same deep-dive format for every stack:

MIT Licensed · Created by Mohammad Garmabi · Not an official TanStack package