A practical guide to HTTP requests, TanStack Query, error handling, and server-side rendering
When building a Vue or Nuxt application, making an HTTP request is rarely the difficult part. The real challenge is keeping API calls consistent as the application grows.
You need to handle authentication, HTTP errors, request cancellation, loading states, caching, and server-side rendering. Before long, your project has multiple API utilities, duplicated interceptors, and slightly different error-handling logic across components.
Nuxt provides $fetch and useFetch, while Vue developers can also use Axios, ofetch, or the native Fetch API. These are all useful tools, but when your application uses TanStack Query, you may want a transport layer designed specifically for its query-function contract.
That's where tanstack-fetch comes in.
In this tutorial, we'll build a reusable API client for Vue 3 and Nuxt, connect it to TanStack Query, and explore when this approach makes sense.
1. Understanding the problem
Consider a typical API request using Axios:
const response = await axios.get<User[]>('/api/users')
return response.data
Axios returns a response object, so we need to extract response.data. We also need to ensure that HTTP errors are handled consistently and that requests can be cancelled when they are no longer needed.
Now consider a TanStack Query query function:
const { data } = useQuery({
queryKey: ['users'],
queryFn: async () => {
const response = await axios.get<User[]>('/api/users')
return response.data
},
})
This works, but every query function may need to account for the HTTP client's response structure, error behavior, and cancellation mechanism.
As the project grows, the same concerns tend to appear in many places.
A better approach is to separate responsibilities:
- HTTP client: Sends requests and handles transport-level behavior.
- TanStack Query: Manages caching, retries, server state, and query lifecycle.
- Vue or Nuxt: Renders the UI and manages application-level concerns.
The goal isn't to replace TanStack Query. It's to give it a cleaner HTTP client to work with.
2. What is tanstack-fetch?
tanstack-fetch is an open-source, TypeScript-first HTTP client created by Mohammad Garmabi. It is designed around the way TanStack Query expects query functions to behave.
It is an independent package, not an official TanStack project.
Its core contract is simple:
- Return parsed data when a request succeeds.
- Throw a typed
FetchErrorwhen an HTTP request fails. - Honor
AbortSignalso requests can be cancelled. - Keep authentication, interceptors, and other transport concerns in a reusable client.
For Vue and Nuxt, the package also provides optional integration helpers through tanstack-fetch/vue.
This means you can use the same underlying HTTP client without tying your API layer to a particular UI framework.
Documentation: https://mohamadgarmabi.github.io/tanstack-fetch/guide/vue
3. Installing the dependencies
For a Vue 3 application, install the client and Vue integration:
npm install tanstack-fetch vue
If you also want caching and server-state management with TanStack Query, install Vue Query:
npm install @tanstack/vue-query
For a Nuxt application, the client can be installed with:
npm install tanstack-fetch
Add @tanstack/vue-query if you plan to use TanStack Query.
One important distinction: tanstack-fetch and TanStack Query solve different problems. The first handles HTTP transport; the second manages server state.
4. Creating a reusable API client in Vue
Let's start with a simple Vue 3 application.
Instead of creating a new HTTP client inside every component, we'll create one shared instance.
Create src/lib/api.ts:
import { createFetch } from 'tanstack-fetch'
export interface User {
id: string
name: string
email: string
}
export const api = createFetch({
baseUrl: import.meta.env.VITE_API_URL,
})
Define the API URL in your .env file:
VITE_API_URL=https://api.example.com
Now the application has one place to configure its HTTP client.
You can add authentication, status handlers, and plugins as your requirements evolve, instead of distributing those concerns across individual requests.
Connecting the client to Vue
The Vue integration provides createFetchPlugin and a useFetch composable.
In src/main.ts:
import { createApp } from 'vue'
import { createFetchPlugin } from 'tanstack-fetch/vue'
import { api } from './lib/api'
import App from './App.vue'
createApp(App)
.use(createFetchPlugin({ client: api }))
.mount('#app')
The client is now available through the Vue integration.
Inside a component:
<script setup lang="ts">
import { useFetch } from 'tanstack-fetch/vue'
const api = useFetch()
const users = await api.get<User[]>('/users', {})
</script>
For applications that need shared request caching, however, we'll usually let TanStack Query manage the request lifecycle.
5. Integrating tanstack-fetch with TanStack Query
This is where the client becomes especially useful.
Install Vue Query if you haven't already:
npm install @tanstack/vue-query
Register its plugin in your Vue application:
import { createApp } from 'vue'
import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query'
import { createFetchPlugin } from 'tanstack-fetch/vue'
import { api } from './lib/api'
import App from './App.vue'
const queryClient = new QueryClient()
const app = createApp(App)
app.use(VueQueryPlugin, { queryClient })
app.use(createFetchPlugin({ client: api }))
app.mount('#app')
Now create a component that fetches users.
<script setup lang="ts">
import { useQuery } from '@tanstack/vue-query'
import { useFetch } from 'tanstack-fetch/vue'
import type { User } from '@/lib/api'
const api = useFetch()
const {
data: users,
error,
isPending,
isError,
} = useQuery({
queryKey: ['users'],
queryFn: ({ signal }) =>
api.get<User[]>('/users', { signal }),
})
</script>
<template>
<p v-if="isPending">Loading users...</p>
<p v-else-if="isError">
Failed to load users: {{ error.message }}
</p>
<ul v-else>
<li v-for="user in users" :key="user.id">
{{ user.name }} — {{ user.email }}
</li>
</ul>
</template>
Let's look at the important parts.
The query key
queryKey: ['users']
TanStack Query uses this key to identify and cache the query.
The query function
queryFn: ({ signal }) =>
api.get<User[]>('/users', { signal })
The HTTP client returns the parsed response data directly, so there is no need to unwrap an Axios response object.
Request cancellation
TanStack Query provides an AbortSignal to the query function. Passing it to api.get allows the HTTP request to participate in Query's cancellation lifecycle.
This matters when a query becomes obsolete or is cancelled during navigation or other application interactions.
Error handling
When the request fails with an HTTP error, tanstack-fetch throws a FetchError. TanStack Query can then expose the failure through its normal error state.
The result is a clear separation of responsibilities: the HTTP client handles the request, and TanStack Query manages the server state.
6. Using mutations to create data
The same approach works for POST requests.
Suppose we want to create a user and refresh the cached user list after a successful response.
<script setup lang="ts">
import {
useMutation,
useQueryClient,
} from '@tanstack/vue-query'
import { useFetch } from 'tanstack-fetch/vue'
import type { User } from '@/lib/api'
const api = useFetch()
const queryClient = useQueryClient()
interface CreateUserInput {
name: string
email: string
}
const createUser = useMutation({
mutationFn: (body: CreateUserInput) =>
api.post<User>('/users', { body }),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: ['users'],
})
},
})
</script>
<template>
<button
:disabled="createUser.isPending.value"
@click="
createUser.mutate({
name: 'Ada Lovelace',
email: 'ada@example.com',
})
"
>
Create user
</button>
<p v-if="createUser.isError.value">
{{ createUser.error.value.message }}
</p>
</template>
This example focuses on the API integration. In a production component, the input values would normally come from a form with validation.
The key idea is that mutations use the same API client as queries. You don't need a separate HTTP implementation for writes.
7. Setting up tanstack-fetch in Nuxt
Nuxt introduces another concern: the same application code may run on the server and in the browser.
Nuxt already provides $fetch and useFetch, so why introduce another client?
The answer depends on your architecture.
If you only need straightforward requests in Nuxt pages, the built-in tools may be sufficient. But if you want a shared HTTP client for TanStack Query, consistent typed HTTP errors, or features such as authenticated server-sent events, tanstack-fetch offers another option.
Let's register a shared client through a Nuxt plugin.
Create plugins/tanstack-fetch.ts:
import { createFetch } from 'tanstack-fetch/sse'
import { createFetchPlugin } from 'tanstack-fetch/vue'
export default defineNuxtPlugin((nuxtApp) => {
const config = useRuntimeConfig()
const api = createFetch({
baseUrl: config.public.apiBase,
})
nuxtApp.vueApp.use(
createFetchPlugin({ client: api }),
)
return {
provide: {
api,
},
}
})
Add the public API URL to nuxt.config.ts:
export default defineNuxtConfig({
runtimeConfig: {
public: {
apiBase: 'https://api.example.com',
},
},
})
The plugin registers the Vue integration and exposes the client through Nuxt's application context.
The example imports from tanstack-fetch/sse because that entry point also supports server-sent events. If you only need HTTP requests, use the core entry point instead:
import { createFetch } from 'tanstack-fetch'
Accessing the client in a Nuxt page
You can access the injected client through useNuxtApp():
<script setup lang="ts">
import type { User } from '@/lib/api'
const { $api } = useNuxtApp()
const users = await $api.get<User[]>('/users', {})
</script>
For a Nuxt application using TanStack Query, you can instead access the Vue composable:
<script setup lang="ts">
import { useQuery } from '@tanstack/vue-query'
import { useFetch as useFetchClient } from 'tanstack-fetch/vue'
interface User {
id: string
name: string
}
const api = useFetchClient()
const { data: users, isPending } = useQuery({
queryKey: ['users'],
queryFn: ({ signal }) =>
api.get<User[]>('/users', { signal }),
})
</script>
<template>
<p v-if="isPending">Loading...</p>
<ul v-else>
<li v-for="user in users" :key="user.id">
{{ user.name }}
</li>
</ul>
</template>
Notice the alias:
import { useFetch as useFetchClient } from 'tanstack-fetch/vue'
Nuxt already has a composable named useFetch. Renaming the import avoids confusing Nuxt's built-in data-fetching composable with the tanstack-fetch client composable.
8. What about authentication and HTTP errors?
Real applications need more than successful GET requests.
Imagine an API that returns:
-
401 Unauthorizedwhen the access token is invalid. -
403 Forbiddenwhen the current user lacks permission. -
404 Not Foundwhen a resource does not exist. -
429 Too Many Requestswhen a rate limit is reached. -
500 Internal Server Errorwhen the server encounters a problem.
You can configure status handlers on the shared client rather than duplicating the same logic in every query function.
For example:
import { createFetch } from 'tanstack-fetch'
const api = createFetch({
baseUrl: 'https://api.example.com',
onUnauthorized: () => {
// Trigger your application's authentication flow.
},
onForbidden: ({ status }) => {
console.warn('Access denied:', status)
},
onNotFound: ({ status }) => {
console.warn('Resource not found:', status)
},
})
These handlers are illustrative: connect them to your actual authentication and notification systems.
One security detail is important: a 403 response should not automatically be treated as an expired session. A user may be authenticated but lack permission to access a resource. Keep authorization failures distinct from authentication failures.
If your application uses refresh tokens, configure the appropriate refresh-token interceptor rather than implementing refresh logic independently in every component.
Also remember that a shared client does not replace server-side authorization. The backend must enforce permissions on every protected endpoint.
9. Server-side rendering and cookies in Nuxt
SSR is one of the most important architectural differences between Vue SPA development and Nuxt.
During server rendering, the browser's cookies are not automatically available to outgoing API requests. If the API relies on session cookies, your server-side client may need to forward the relevant incoming request headers.
tanstack-fetch provides an ssr-forward plugin for this use case.
The general setup involves:
- Reading the relevant incoming request headers on the server.
- Forwarding the required cookies or authorization headers to the upstream API.
- Avoiding unnecessary forwarding in browser-side requests.
- Ensuring that each server request uses the correct user's authentication context.
For example, the configuration can use the relevant plugin:
import { createFetch } from 'tanstack-fetch/plugins'
const api = createFetch({
baseUrl: 'https://api.example.com',
plugins: ['ssr-forward'],
})
Treat this as an illustration of the plugin entry point, not a complete Nuxt SSR configuration. Actual header forwarding must be wired to the incoming Nuxt request using the documented SSR integration.
See the official SSR guide for the Nuxt-specific setup.
Important: Never forward all incoming headers indiscriminately, expose private credentials through runtimeConfig.public, or store user-specific authentication state in a process-wide singleton. Incorrect SSR authentication can leak data between users.
For a complete Nuxt application, configure Vue Query's server-side prefetching and hydration separately as well. Registering an HTTP client alone does not automatically dehydrate and hydrate the query cache.
10. When should you use tanstack-fetch instead of $fetch?
Nuxt's built-in $fetch is a good choice for server routes, simple API calls, and applications that don't need a separate transport layer.
tanstack-fetch becomes more interesting when your application needs a consistent HTTP contract across several parts of the stack.
| Requirement | Practical choice |
|---|---|
| Simple request in a Nuxt server route | $fetch |
| Nuxt page data without TanStack Query |
useFetch or $fetch
|
| TanStack Query with a shared typed HTTP client | tanstack-fetch |
| Consistent HTTP errors and status handlers | tanstack-fetch |
| Shared transport configuration across Vue and other frameworks | tanstack-fetch |
| Authenticated SSE with reconnection support | tanstack-fetch SSE integration |
The important point isn't that one client is universally better. Each solves a slightly different problem.
If your project is small and $fetch already meets your requirements, adding another abstraction may not be worthwhile.
If your project relies heavily on TanStack Query and needs consistent authentication, error handling, cancellation, and transport configuration, a dedicated client can reduce repeated glue code.
11. Final thoughts
A maintainable frontend architecture doesn't require the largest possible set of abstractions. It requires clear boundaries between responsibilities.
TanStack Query should manage server state. Vue and Nuxt should handle rendering and framework-specific behavior. The HTTP client should provide a predictable interface for communicating with the backend.
tanstack-fetch is designed to connect those layers with a small, typed API that works with Vue 3 and Nuxt.
Start with a single shared client, connect it to your queries, and add authentication, SSR forwarding, or SSE only when your application needs them.
You can explore the package, examples, and framework integrations here:
- Documentation: https://mohamadgarmabi.github.io/tanstack-fetch/
- Vue & Nuxt guide: https://mohamadgarmabi.github.io/tanstack-fetch/guide/vue
- TanStack Query integration: https://mohamadgarmabi.github.io/tanstack-fetch/guide/tanstack-query
- GitHub: https://github.com/mohamadgarmabi/tanstack-fetch
The objective is straightforward: fewer duplicated transport concerns, predictable errors, and a cleaner boundary between HTTP requests and server-state management.
Top comments (0)