Cookbook
One short, copy-paste example per feature. The core snippets are typechecked against the library source in CI (test/cookbook.check.ts), so they stay correct. Want to click instead? See the live playground.
Typed HTTP
ts
import { createFetch, pathParams, type NoParams } from 'tanstack-fetch'
import {
createAuthInterceptor,
createRetryIdempotentInterceptor,
createTraceInterceptor,
} from 'tanstack-fetch/plugins'
const api = createFetch({
baseUrl: 'https://api.example.com',
interceptors: [
createAuthInterceptor({ getToken: () => auth.token }),
createTraceInterceptor(),
createRetryIdempotentInterceptor(),
],
})
// Query string + cancel
const users = await api.get<User[]>('/users', { query: { page: 1, q: 'ada' }, signal })
// Path params are typed from the path: { id } is required here
const user = await api.get('/users/:id', { params: { id: 1 } })
// Response, params map and body types
const created = await api.post<User, NoParams, CreateUser>('/users', { body: { name: 'Ada' } })
await api.patch<User, { id: number }, Partial<CreateUser>>('/users/:id', {
params: { id: 1 },
body: { name: 'Grace' },
})
// Reusable typed params
await api.delete('/users/:id', { params: pathParams('/users/:id', { id: 1 }) })
// Non-JSON responses
const csv = await api.get<string>('/export', { parseAs: 'text' })
const avatar = await api.get<Blob>('/avatar.png', { parseAs: 'blob' })In a TanStack Query queryFn / mutationFn
tsx
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
const useUser = (id: number) =>
useQuery({
queryKey: ['user', id],
queryFn: ({ signal }) => api.get<User>('/users/:id', { params: { id }, signal }),
})
const useCreateUser = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (body: CreateUser) => api.post<User, NoParams, CreateUser>('/users', { body }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['users'] }),
})
}Errors and timeouts
ts
import { isAbortError, isFetchError } from 'tanstack-fetch'
try {
await api.get('/report', { timeoutMs: 5_000 })
} catch (error) {
if (isAbortError(error)) {
if ((error as Error).name === 'TimeoutError') toast.error('Timed out')
// plain AbortError = cancelled (unmount, refetch, signal)
} else if (isFetchError(error)) {
if (error.status === 404) toast.error('Not found')
else toast.error(`${error.code}: ${error.message}`) // error.body has the raw response
}
}More: Errors guide.
Status handlers (401 → 429 → 5xx)
ts
import { createFetch, parseRetryAfter } from 'tanstack-fetch'
import { createAuthInterceptor, createStatusInterceptor } from 'tanstack-fetch/plugins'
import { createFetch as createSseFetch } from 'tanstack-fetch/sse'
const api = createFetch({
baseUrl: 'https://api.example.com',
interceptors: [
createAuthInterceptor({ getToken: () => auth.token }),
createStatusInterceptor({
401: () => {
window.location.href = '/login'
},
403: ({ error }) => toast.error(error.message),
// Respect Retry-After, then retry
429: ({ context }) => ({
action: 'retry',
delayMs: parseRetryAfter(context.response?.headers, 1_000),
}),
'5xx': ({ status }) => toast.error(`Server error ${status}`),
}),
],
})
// Option shortcuts (getToken, onUnauthorized, …) stay on the full clients
const shortcutApi = createSseFetch({
baseUrl: 'https://api.example.com',
getToken: () => auth.token,
onUnauthorized: () => {
window.location.href = '/login'
},
})Try it live: status matrix.
Refresh token (before expiry + after first 401)
ts
import { createAuthInterceptor, createRefreshTokenInterceptor } from 'tanstack-fetch/plugins'
const api = createFetch({
baseUrl: 'https://api.example.com',
interceptors: [
createAuthInterceptor({ getToken: () => auth.token }),
createRefreshTokenInterceptor({
refresh: () => auth.refresh(), // single-flight: parallel 401s share one refresh
before: { getExpiresAt: () => auth.expiresAt, skewMs: 30_000 },
onRefreshFailed: () => {
window.location.href = '/login'
},
}),
],
})The same token is used by api.ws, api.transport and api.stream, and is fetched again on every reconnect. More: Refresh token recipe.
Interceptors and plugins
ts
import type { HttpInterceptor } from 'tanstack-fetch'
const timing: HttpInterceptor = {
name: 'timing',
onRequest: (context) => {
context.request.headers.set('x-started-at', String(Date.now()))
return { action: 'continue', context }
},
onResponseError: (context) => {
console.warn(context.request.url.pathname, context.error?.status)
return { action: 'continue', context }
},
}
api.use('timing', timing)
// Turn a plugin off for one call (never retry a payment)
await api.post('/payments', { body: { amount: 10 }, interceptors: { eject: ['retry-idempotent'] } })
// Mock one call without a server
await api.get('/flags', {
interceptors: {
use: [
{
name: 'mock-flags',
onRequest: () => ({
action: 'short-circuit',
result: { ok: true, status: 200, data: { beta: true }, headers: new Headers() },
}),
},
],
},
})Built-in plugins (tanstack-fetch/plugins): createTraceInterceptor, createRetryIdempotentInterceptor, createSsrForwardInterceptor, createSseResumeInterceptor. More: Plugins.
Upload and download progress
ts
import { createFetch } from 'tanstack-fetch/sse' // upload + progress need the full client
const api = createFetch({ baseUrl: 'https://api.example.com' })
// Single file + extra fields
await api.upload<{ id: string }>('/avatars', {
file,
fields: { userId: 1 },
onUploadProgress: ({ progress }) => setPercent(Math.round((progress ?? 0) * 100)),
})
// Several files, custom field name and method
await api.upload('/gallery', { files, fieldName: 'photos', method: 'PUT' })
// Download progress on any request
await api.get<Blob>('/exports/big.zip', {
parseAs: 'blob',
onDownloadProgress: ({ loaded, total }) => setLabel(`${loaded}/${total ?? '?'}`),
})Live demo: Upload.
SSR: forward cookies (Next.js, Nuxt)
ts
// app/lib/server-api.ts — Next.js App Router
import { cookies, headers } from 'next/headers'
import { createFetch } from 'tanstack-fetch'
import { createSsrForwardInterceptor } from 'tanstack-fetch/plugins'
export const serverApi = createFetch({
baseUrl: process.env.API_URL,
source: 'ssr',
interceptors: [createSsrForwardInterceptor()],
incoming: async () => ({
cookie: (await cookies()).toString(),
authorization: (await headers()).get('authorization') ?? undefined,
}),
})More: SSR guide · Next.js demo.
Live: one client for every transport
ts
import { createFetch } from 'tanstack-fetch/live'
const live = createFetch({
baseUrl: 'https://api.example.com',
getToken: () => auth.token,
plugins: ['sse-resume'],
})SSE: notifications
ts
const feed = live.sse<Notification>('/notifications', { onMessage: (n) => notify(n) })
feed.close()
// or iterate, and stop on a named event
for await (const event of live.sse<Notification>('/notifications', { signal })) {
if (event.event === 'done') break
}WebSocket: chat with typing and binary frames
ts
type ChatMessage = { type: 'message' | 'typing'; user: string; text?: string }
const room = live.ws<ChatMessage, { id: string }, ChatMessage | ArrayBuffer>('/rooms/:id', {
params: { id: 'general' },
query: { since: '2026-10-01' },
authIn: 'query', // browsers can't send WebSocket headers
onMessage: (message) => (message.type === 'typing' ? showTyping(message.user) : add(message)),
})
room.send({ type: 'typing', user: 'ada' }) // JSON
room.send(new ArrayBuffer(8)) // binary is sent as-isWebSocket: subprotocol auth, no reconnect, cancel with a signal
ts
const controller = new AbortController()
live.ws('/feed', {
authIn: 'protocol', // token is appended to Sec-WebSocket-Protocol
protocols: ['feed.v2'],
reconnect: false,
signal: controller.signal,
parseAs: 'text', // keep raw strings
})
controller.abort() // closes the socketWebSocket: another host, custom token key
ts
live.ws('wss://realtime.example.com/socket', { authIn: 'query', authQueryKey: 'access_token' })WebSocket on Node: real Authorization header
ts
// Node 22+ / Bun / Deno: the header is sent by default
const api = createFetch({ baseUrl: 'https://api.example.com', getToken })
api.ws('/jobs/:id', { params: { id }, onMessage: log })
// Node < 22: plug in the `ws` package
import WS from 'ws'
createFetch({
baseUrl: 'https://api.example.com',
createWebSocket: (url, { protocols, headers }) => new WS(url, protocols, { headers }) as never,
})WebTransport: multiplayer state
ts
const game = live.transport<{ x: number; y: number }, { id: number }, { dx: number }>('/game/:id', {
params: { id: 1 },
authIn: 'query',
webTransport: { congestionControl: 'low-latency' },
onMessage: (state) => draw(state), // unreliable datagrams: newest value wins
})
game.send({ dx: 1 })NDJSON: LLM tokens, export progress, log tail
ts
// LLM answer, token by token
let answer = ''
for await (const chunk of live.stream<{ text: string }>('/chat', { body: { prompt }, signal })) {
answer += chunk.text
}
// Long export with progress rows
for await (const row of live.stream<{ done: number; total: number }>('/exports', {
method: 'POST',
body: { format: 'csv' },
})) {
setProgress(row.done / row.total)
}
// Tail logs until a line shows up (break cancels the request)
for await (const line of live.stream<string>('/logs/tail', { query: { service: 'api' } })) {
if (line.includes('READY')) break
}Log every live connection (interceptor hooks)
ts
live.use('live-log', {
onLiveOpen: (context) => {
console.log('open', context.meta.transport, context.request.url.pathname)
return { action: 'continue', context }
},
onLiveMessage: (context) => {
console.log(context.message.direction, context.message.data) // 'in' | 'out'
return { action: 'continue', context }
},
onLiveClose: (context) => {
if (context.error) console.warn('closed', context.error.message)
return { action: 'continue', context }
},
})Live demos: WebSocket · WebTransport · NDJSON.
Framework hooks
tsx
import { useSse, useWs } from 'tanstack-fetch/react'
const Dashboard = () => {
const orders = useSse<Order>('/orders/stream', { client: live })
const chat = useWs<ChatMessage>('/rooms/general', { client: live, authIn: 'query' })
return (
<>
<p>
Last order: {orders.data?.id} ({orders.status})
</p>
<p>
Chat: {chat.data?.text} ({chat.status})
</p>
<button onClick={() => chat.send({ type: 'message', user: 'me', text: 'hi' })}>Send</button>
</>
)
}vue
<script setup lang="ts">
import { useSse, useWs } from 'tanstack-fetch/vue'
const orders = useSse<Order>('/orders/stream', { client: live })
const chat = useWs<ChatMessage>('/rooms/general', { client: live, authIn: 'query' })
</script>
<template>
<p>Last order: {{ orders.data.value?.id }} ({{ orders.status.value }})</p>
<button @click="chat.send({ type: 'message', user: 'me', text: 'hi' })">Send</button>
</template>tsx
import { useWs } from 'tanstack-fetch/solid'
const Chat = () => {
const { data, status, send } = useWs<ChatMessage>('/rooms/general', {
client: live,
authIn: 'query',
})
return (
<button onClick={() => send({ type: 'message', user: 'me', text: 'hi' })}>{status()}</button>
)
}svelte
<script lang="ts">
import { useWs } from 'tanstack-fetch/svelte'
const { data, status, send } = useWs<ChatMessage>('/rooms/general', {
client: live,
authIn: 'query',
})
</script>
<p>{$status} · {$data?.text}</p>ts
import { useWs } from 'tanstack-fetch/angular'
@Component({ template: `<p>{{ chat.status() }} · {{ chat.data()?.text }}</p>` })
export class ChatComponent {
readonly chat = useWs<ChatMessage>('/rooms/general', { client: live, authIn: 'query' })
}Streamed query (NDJSON + TanStack Query)
tsx
import { experimental_streamedQuery as streamedQuery, useQuery } from '@tanstack/react-query'
const Answer = ({ prompt }: { prompt: string }) => {
const { data: tokens = [] } = useQuery({
queryKey: ['chat', prompt],
queryFn: streamedQuery({
streamFn: ({ signal }) => live.stream<Token>('/chat', { body: { prompt }, signal }),
}),
})
return <p>{tokens.map((t) => t.text).join('')}</p>
}Live updates into the Query cache
tsx
const queryClient = useQueryClient()
useWs<Order>('/orders/live', {
client: live,
authIn: 'query',
onMessage: (order) =>
queryClient.setQueryData<Order[]>(['orders'], (prev = []) => [order, ...prev]),
})tRPC on the same client
ts
import { createTRPCFetchClient } from 'tanstack-fetch/trpc'
import type { AppRouter } from './server'
export const trpc = createTRPCFetchClient<AppRouter>({ url: '/api/trpc', client: api })
const posts = await trpc.post.list.query()More: tRPC guide.
DevTools
ts
import { setupDevtools } from 'tanstack-fetch/devtools'
setupDevtools(live) // HTTP · SSE · SSR · tRPC · WS · Stream tabs; Alt+Shift+F togglesLive demo: DevTools.
CLI
bash
# Typed client from an OpenAPI document
npx tanstack-fetch generate --spec ./openapi.yaml --out ./src/api
# Health check: deps, createFetch usage, missing signal
npx tanstack-fetch doctor --dir ./src
# Rewrite axios / ky / ofetch / fetch call sites and scaffold a framework
npx tanstack-fetch migrate --from axios --framework react --dir ./src --write