npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

tanstack-fetch

v1.1.0

Published

React Query / TanStack Query fetch client — typed errors, AbortSignal, SSR, SSE, upload. Lightweight axios/ky alternative (~3.5KB).

Readme

tanstack-fetch

Typed Fetch client designed for TanStack Query.

A react-query fetch client / axios alternative for TanStack Query — tiny HTTP core, typed errors, AbortSignal, SSR, SSE, plugins.

Tiny HTTP core · Typed errors · 401 / 403 / 404 / 5xx handling · AbortSignal · SSE · SSR · Plugins · React

npm version npm downloads bundle size license CI TypeScript

30-second quickstart

npm install tanstack-fetch @tanstack/react-query
import { createFetch } from 'tanstack-fetch'
import { useQuery } from '@tanstack/react-query'

const api = createFetch({ baseUrl: 'https://api.example.com' })

useQuery({
  queryKey: ['users'],
  queryFn: ({ signal }) => api.get<User[]>('/users', { signal }),
})

That’s it: returns data, throws FetchError on HTTP errors, honors Query’s signal.

Docs: mohamadgarmabi.github.io/tanstack-fetch · Examples: examples/ · Changelog: CHANGELOG.md

Not an official TanStack package — built to match the @tanstack/react-query mental model.


Why tanstack-fetch

Built for the way TanStack Query actually works: return data, throw on failure, honor signal.

| | | | ------------------- | ------------------------------------------------------------------- | | Tiny HTTP core | ~3.5KB gzip — tree-shakeable entry points | | Typed errors | FetchError with status, code, body + isFetchError() | | Status handling | First-class 401 / 403 / 404 / 5xx (and onStatus map) | | AbortSignal | Pass Query’s signal — cancels cleanly, no false errors | | SSE | Streams over fetch (auth + cookies work) via tanstack-fetch/sse | | SSR | Next.js-ready cookie / header forwarding (ssr-forward) | | Plugins | Named interceptors: retry, trace, mocks, eject per request | | React | Optional FetchProvider, useFetch, useSse | | tRPC | tanstack-fetch/trpc — same auth/plugins with Router & Start |

Also: multipart upload + progress, OpenAPI codegen CLI, Edge-friendly.

Compared to axios / ky / ofetch: same mental model as TanStack Query queryFn, ~3.5KB core, SSR cookie forwarding, and SSE with Authorization — without pulling a large HTTP stack.


Quick taste

import { createFetch, isFetchError } from 'tanstack-fetch'

const api = createFetch({
  baseUrl: import.meta.env.VITE_API_URL,
  getToken: () => localStorage.getItem('access_token'),
  onUnauthorized: () => {
    localStorage.removeItem('access_token')
    window.location.href = '/login'
  },
})

const user = await api.get<User>('/users/:id', { params: { id: '1' } })

try {
  await api.get('/missing')
} catch (error) {
  if (isFetchError(error)) console.log(error.status, error.message)
}

Bundle size

| Import | What you get | Typical gzip | | ------------------------ | -------------------------- | ------------ | | tanstack-fetch | HTTP (get/post/upload/…) | ~3.5KB | | tanstack-fetch/sse | + api.sse() | ~4.7KB | | tanstack-fetch/plugins | plugin factories | ~0.9KB | | tanstack-fetch/react | FetchProvider / hooks | ~1KB | | tanstack-fetch/trpc | tRPC link via createFetch | ~3.1KB |

import { createFetch } from 'tanstack-fetch' // HTTP only
import { createFetch } from 'tanstack-fetch/sse' // + streams

yaml and React are optional peers. Run npm run size after build for local gzip numbers.


tRPC (React · Router · Start)

Same createFetch client — drop into tRPC:

npm install tanstack-fetch @trpc/client @trpc/tanstack-react-query @tanstack/react-query
import { createFetch } from 'tanstack-fetch'
import { createTRPCFetchClient } from 'tanstack-fetch/trpc'
import { createTRPCOptionsProxy } from '@trpc/tanstack-react-query'
import { QueryClient } from '@tanstack/react-query'
import type { AppRouter } from './server'

const api = createFetch({
  getToken: () => localStorage.getItem('access_token'),
  onUnauthorized: () => localStorage.removeItem('access_token'),
})

const queryClient = new QueryClient()
const trpcClient = createTRPCFetchClient<AppRouter>({
  url: '/api/trpc',
  client: api,
})
const trpc = createTRPCOptionsProxy<AppRouter>({ client: trpcClient, queryClient })

// React / Router / Start
useQuery(trpc.post.list.queryOptions())

Full setup (Router loaders, TanStack Start SSR): docs/recipes/trpc.md


Compared to axios / ky / ofetch

| Need | tanstack-fetch | | ---------------------------------- | ------------------------------------------------- | | Drop into TanStack Query queryFn | Returns data, throws FetchError, takes signal | | Next.js SSR cookies | ssr-forward plugin | | Interceptors without axios weight | Named, ordered, ejectable plugins | | SSE with Authorization | tanstack-fetch/sse (not EventSource) | | File upload + progress | api.upload() + onUploadProgress | | Bundle | ~3.5KB gzip HTTP core |

Recipes: refresh token on 401 · examples · social post draft


Two ways to configure

1) Simple path — baseUrl, token, status handlers

Most apps only need this: set API URL, attach a token, and decide what happens on 401 / 403 / 404 / 5xx.

import { createFetch } from 'tanstack-fetch'

export const api = createFetch({
  baseUrl: import.meta.env.VITE_API_URL,
  getToken: () => localStorage.getItem('access_token'),

  onUnauthorized: () => {
    localStorage.removeItem('access_token')
    window.location.href = '/login' // 401
  },
  onForbidden: () => {
    console.warn('No permission') // 403
  },
  onNotFound: ({ error }) => {
    console.warn('Missing resource', error.message) // 404
  },
  onServerError: ({ status }) => {
    console.error('Server error', status) // 500–599
  },
})

Handlers run before the error is thrown (so TanStack Query still gets isError / FetchError).

| Option | When | | ------------------- | -------------------------------------------------- | | getToken / auth | Every request — sets Authorization: Bearer … | | onUnauthorized | HTTP 401 | | onForbidden | HTTP 403 | | onNotFound | HTTP 404 | | onServerError | HTTP 5xx | | onStatus | Advanced map (exact code, 4xx, 5xx, default) |

createFetch({
  baseUrl: 'https://api.example.com',
  auth: {
    getToken: async () => (await cookies()).get('token')?.value,
    header: 'authorization',
    scheme: 'Bearer', // use '' for a raw token / API key
  },
  onStatus: {
    401: () => redirect('/login'),
    403: () => toast.error('Forbidden'),
    404: () => toast.error('Not found'),
    500: () => toast.error('Server error'),
    '5xx': ({ status }) => console.error('upstream', status),
    default: ({ status }) => console.warn('unhandled', status),
  },
})

2) Advanced path — plugins + custom interceptors

Full control: plugins, named interceptors, per-request eject, match filters.

import { createFetch } from 'tanstack-fetch'

export const api = createFetch({
  baseUrl: 'https://api.example.com',
  plugins: ['trace', 'ssr-forward', 'retry-idempotent', 'sse-resume'],
  getToken: () => getAccessToken(),
  onUnauthorized: () => logout(),
  interceptors: [
    {
      name: 'locale',
      order: 25,
      onRequest: (context) => {
        context.request.headers.set('accept-language', 'fa')
        return { action: 'continue', context }
      },
    },
  ],
})

api.use('audit', {
  onResponse: (context) => {
    console.log(context.response?.status, context.request.url.pathname)
    return { action: 'continue', context }
  },
})

React FetchProvider (optional)

Same config, shared via context — like wrapping your app once.

import { FetchProvider, useFetch } from 'tanstack-fetch/react'
import { useQuery } from '@tanstack/react-query'
import { isFetchError } from 'tanstack-fetch'

const App = () => (
  <FetchProvider
    baseUrl={import.meta.env.VITE_API_URL}
    getToken={() => localStorage.getItem('access_token')}
    onUnauthorized={() => {
      localStorage.removeItem('access_token')
      window.location.href = '/login'
    }}
    onForbidden={() => console.warn('403')}
    onNotFound={() => console.warn('404')}
    onServerError={({ status }) => console.error('5xx', status)}
    plugins={['trace', 'retry-idempotent']}
  >
    <UsersPage />
  </FetchProvider>
)

const UsersPage = () => {
  const api = useFetch()
  const { data, error, isPending } = useQuery({
    queryKey: ['users'],
    queryFn: ({ signal }) => api.get<User[]>('/users', { signal }),
  })

  if (isPending) return <p>Loading…</p>
  if (isFetchError(error))
    return (
      <p>
        {error.status}: {error.message}
      </p>
    )
  return (
    <ul>
      {data.map((u) => (
        <li key={u.id}>{u.name}</li>
      ))}
    </ul>
  )
}

Or pass an existing client:

const api = createFetch({ baseUrl: '…', getToken: … })

<FetchProvider client={api}>
  <App />
</FetchProvider>

Why this API matches TanStack Query

| TanStack Query needs | tanstack-fetch does | | ---------------------- | ------------------------------------------------ | | queryFn returns data | api.get<T>()Promise<T> | | Failures must throw | HTTP errors throw FetchError | | Cancellation | Pass { signal } from queryFn context | | Typed errors | isFetchError(error)status, code, body |

Install peers

npm install tanstack-fetch @tanstack/react-query

Shared client

// src/lib/api.ts
import { createFetch } from 'tanstack-fetch'

export const api = createFetch({
  baseUrl: import.meta.env.VITE_API_URL ?? 'https://api.example.com',
  plugins: ['trace', 'retry-idempotent'],
})

useQuery — basic

import { useQuery } from '@tanstack/react-query'
import { isFetchError } from 'tanstack-fetch'
import { api } from '#/lib/api'

type User = { id: string; name: string }

const UsersPage = () => {
  const { data, error, isPending, isFetching, refetch } = useQuery({
    queryKey: ['users'],
    queryFn: ({ signal }) => api.get<User[]>('/users', { signal }),
  })

  if (isPending) return <p>Loading…</p>
  if (isFetchError(error))
    return (
      <p>
        {error.status}: {error.message}
      </p>
    )
  if (error) return <p>Something went wrong</p>

  return (
    <div>
      <button onClick={() => refetch()} disabled={isFetching}>
        Refresh
      </button>
      <ul>
        {data.map((user) => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    </div>
  )
}

export default UsersPage

useQuery — with params + enabled

import { useQuery } from '@tanstack/react-query'
import { isFetchError } from 'tanstack-fetch'
import { api } from '#/lib/api'

type User = { id: string; name: string; email: string }

const UserDetail = ({ userId }: { userId?: string }) => {
  const { data, error, isPending } = useQuery({
    queryKey: ['users', userId],
    enabled: Boolean(userId),
    queryFn: ({ signal }) =>
      api.get<User>('/users/:id', {
        params: { id: userId! },
        signal,
      }),
  })

  if (!userId) return <p>Select a user</p>
  if (isPending) return <p>Loading…</p>
  if (isFetchError(error)) {
    if (error.status === 404) return <p>User not found</p>
    return (
      <p>
        {error.code}: {error.message}
      </p>
    )
  }
  if (error) return <p>Something went wrong</p>

  return (
    <article>
      <h1>{data.name}</h1>
      <p>{data.email}</p>
    </article>
  )
}

export default UserDetail

useQuery + official queryOptions

Share the same options between components, prefetch, and SSR:

// src/queries/users.ts
import { queryOptions } from '@tanstack/react-query'
import { api } from '#/lib/api'

type User = { id: string; name: string }

export const usersQueryOptions = queryOptions({
  queryKey: ['users'],
  queryFn: ({ signal }) => api.get<User[]>('/users', { signal }),
})

export const userQueryOptions = (id: string) =>
  queryOptions({
    queryKey: ['users', id],
    queryFn: ({ signal }) => api.get<User>('/users/:id', { params: { id }, signal }),
  })
import { useQuery } from '@tanstack/react-query'
import { userQueryOptions, usersQueryOptions } from '#/queries/users'

const UsersPage = () => {
  const { data: users } = useQuery(usersQueryOptions)
  return (
    <ul>
      {users?.map((u) => (
        <li key={u.id}>{u.name}</li>
      ))}
    </ul>
  )
}

const UserPage = ({ id }: { id: string }) => {
  const { data: user } = useQuery(userQueryOptions(id))
  return <h1>{user?.name}</h1>
}

useMutation + invalidate

import { useMutation, useQueryClient } from '@tanstack/react-query'
import { isFetchError } from 'tanstack-fetch'
import { api } from '#/lib/api'

type CreateUser = { name: string; email: string }
type User = CreateUser & { id: string }

const CreateUserForm = () => {
  const queryClient = useQueryClient()

  const mutation = useMutation({
    mutationFn: (body: CreateUser) => api.post<User>('/users', { body }),
    onSuccess: () => {
      void queryClient.invalidateQueries({ queryKey: ['users'] })
    },
  })

  return (
    <form
      onSubmit={(event) => {
        event.preventDefault()
        const form = new FormData(event.currentTarget)
        mutation.mutate({
          name: String(form.get('name')),
          email: String(form.get('email')),
        })
      }}
    >
      <input name="name" />
      <input name="email" type="email" />
      <button type="submit" disabled={mutation.isPending}>
        Create
      </button>
      {isFetchError(mutation.error) && (
        <p>
          {mutation.error.status}: {mutation.error.message}
        </p>
      )}
    </form>
  )
}

export default CreateUserForm

Provider setup

import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useState } from 'react'

const App = ({ children }: { children: React.ReactNode }) => {
  const [queryClient] = useState(() => new QueryClient())

  return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
}

export default App

Quick start

import { createFetch, isFetchError } from 'tanstack-fetch'

const api = createFetch({
  baseUrl: 'https://api.example.com',
})

// Success → data
const user = await api.get<User>('/users/:id', { params: { id: '1' } })
user.name

// Failure → throws FetchError (like queryFn)
try {
  await api.get('/missing')
} catch (error) {
  if (isFetchError(error)) {
    error.status // 404
    error.code
    error.body
  }
}

Need a Result union instead? Opt out per client or per call:

const api = createFetch({ baseUrl: '...', throwOnError: false })

const result = await api.get<User>('/users/1', { throwOnError: false })
if (result.ok) result.data
else result.error

Create a client (createFetch)

Same spirit as createQueryClient — one shared client, default options, plugins.

import { createFetch } from 'tanstack-fetch'
import { cookies } from 'next/headers'

export const api = createFetch({
  baseUrl: process.env.API_URL ?? process.env.NEXT_PUBLIC_API_URL,
  source: 'ssr', // 'browser' | 'ssr' | 'edge'
  timeoutMs: 15_000,
  maxRetries: 2,
  throwOnError: true, // default — Query-friendly
  credentials: 'include',
  headers: { 'x-app': 'web' },
  plugins: ['trace', 'ssr-forward', 'retry-idempotent', 'sse-resume'],
  incoming: async () => ({
    cookie: (await cookies()).toString(),
  }),
})

| Option | Default | Notes | | -------------- | ------------------ | --------------------------------- | | baseUrl | — | Absolute URL required on SSR/Edge | | source | 'browser' | Runtime | | throwOnError | true | falseFetchResult | | plugins | [] | Built-in interceptors | | timeoutMs | 30000 | Combined with Query signal | | maxRetries | 2 | For interceptor retry actions | | fetch | globalThis.fetch | Inject in tests |


HTTP

await api.get<User[]>('/users')
await api.get<User>('/users/:id', { params: { id: '42' }, signal })
await api.get<User[]>('/users', { query: { page: 1, active: true } })

await api.post<User>('/users', { body: { name: 'Ada' } })
await api.put<User>('/users/:id', { params: { id: '42' }, body: { name: 'Ada' } })
await api.patch<User>('/users/:id', { params: { id: '42' }, body: { email: '[email protected]' } })
await api.delete<void>('/users/:id', { params: { id: '42' } })

await api.request<User>('GET', '/users/:id', { params: { id: '1' } })

Upload

FormData / Blob / File are sent as-is (no JSON, no forced Content-Type — the boundary stays correct).

The call still returns the typed response body from the server (same as post), not a special upload envelope.

api.upload() — file + fields + progress

type UploadResponse = { id: string; url: string }

const file = input.files[0]

const uploaded = await api.upload<UploadResponse>('/files', {
  file,
  fieldName: 'avatar', // default: 'file'
  fields: { folder: 'avatars', public: true },
  onUploadProgress: ({ loaded, total, progress }) => {
    // progress is 0–1 when total is known (browser / XHR)
    console.log(loaded, total, progress)
  },
})

uploaded.url

Multiple files or raw FormData:

await api.upload('/docs', {
  method: 'PUT',
  files: [fileA, fileB],
  fieldName: 'docs',
})

await api.upload<UploadResponse>('/files', {
  body: createFormData({ file, note: 'cv' }),
})

createFormData helper

import { createFetch, createFormData } from 'tanstack-fetch'

const api = createFetch({ baseUrl: 'https://api.example.com' })

const body = createFormData({
  title: 'Report',
  tags: ['a', 'b'], // repeated field
  file,
})

await api.post<UploadResponse>('/files', {
  body,
  onUploadProgress: ({ progress }) => console.log(progress),
})

| Option | Notes | | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | | file / files | Appended under fieldName (default "file") | | fields | Extra multipart values (string / number / boolean / Blob / arrays) | | body | Pre-built FormData / Blob / … | | method | POST (default), PUT, or PATCH | | onUploadProgress | Browser-only — uses XHR under the hood (fetch has no upload progress). No-ops on runtimes without XMLHttpRequest (falls back to fetch) |

Works with TanStack Query mutations the same way as post:

useMutation({
  mutationFn: (file: File) =>
    api.upload<UploadResponse>('/files', {
      file,
      onUploadProgress: ({ progress }) => setProgress(progress ?? 0),
    }),
})

FetchError

import { FetchError, isFetchError } from 'tanstack-fetch'

try {
  await api.get('/secure')
} catch (error) {
  if (isFetchError(error)) {
    error.status
    error.code
    error.message
    error.body
    error.headers
    error.result // full FetchResult
  }
}

Abort / cancel from TanStack Query is not wrapped — AbortError propagates so Query can ignore cancelled fetches.


Plugins

Built-in interceptors — enable by name:

createFetch({
  baseUrl: 'https://api.example.com',
  plugins: ['trace', 'ssr-forward', 'retry-idempotent', 'sse-resume'],
})

| Plugin | Role | | ------------------ | --------------------------------------------------------- | | trace | Sets x-request-id | | ssr-forward | Forwards cookie/auth/request-id on SSR (no-op in browser) | | retry-idempotent | Retries GET/HEAD/OPTIONS on 502/503/504 | | sse-resume | Drops heartbeats; sends Last-Event-ID on reconnect |

import { createFetch, createTraceInterceptor, createSsrForwardInterceptor } from 'tanstack-fetch'

const api = createFetch({ baseUrl: 'https://api.example.com' })
api.use('trace', createTraceInterceptor())
api.use('ssr-forward', createSsrForwardInterceptor())

trace

const api = createFetch({
  baseUrl: 'https://api.example.com',
  plugins: ['trace'],
  incoming: { requestId: 'req-from-gateway' },
})
await api.get('/users')
// → header x-request-id: req-from-gateway

ssr-forward (Next.js)

import { cookies, headers } from 'next/headers'
import { createFetch } from 'tanstack-fetch'

export const createServerApi = async () =>
  createFetch({
    baseUrl: process.env.API_URL!,
    source: 'ssr',
    plugins: ['ssr-forward', 'trace', 'retry-idempotent'],
    incoming: async () => {
      const jar = await cookies()
      const h = await headers()
      return {
        cookie: jar.toString(),
        authorization: h.get('authorization') ?? undefined,
        requestId: h.get('x-request-id') ?? undefined,
      }
    },
  })

retry-idempotent

const api = createFetch({
  baseUrl: 'https://api.example.com',
  plugins: ['retry-idempotent'],
  maxRetries: 2,
})

await api.get('/health') // may retry on 503
await api.post('/orders', { body: { sku: 'A' } }) // not retried

sse-resume

import { createFetch } from 'tanstack-fetch/sse'

const api = createFetch({
  baseUrl: 'https://api.example.com',
  plugins: ['sse-resume'],
})

api.sse<OrderEvent>('/orders/stream', {
  onMessage: (data) => {
    // ping / heartbeat never reach here
    console.log(data)
  },
})

Interceptors

Named, ordered, removable — customize auth, logging, mocks.

api.use('auth', {
  order: 20,
  onRequest: async (context) => {
    context.request.headers.set('authorization', `Bearer ${await getToken()}`)
    return { action: 'continue', context }
  },
  onResponseError: async (context) => {
    if (context.error?.status !== 401 || context.meta.attempt > 0) {
      return { action: 'continue', context }
    }
    await refreshToken()
    return { action: 'retry' }
  },
})

api.eject('auth')

// Per-request
await api.get('/public', { interceptors: { eject: ['auth'] } })

Actions

continue · skip · drop (SSE) · retry · short-circuit

Hooks

onRequest · onRequestError · onResponse · onResponseError · onSseOpen · onSseEvent · onSseError · onSseReconnect

Mock short-circuit

api.use('mock-users', {
  match: { pathPrefix: '/users' },
  onRequest: () => ({
    action: 'short-circuit',
    result: {
      ok: true,
      status: 200,
      data: [{ id: '1', name: 'Ada' }],
      headers: new Headers(),
    },
  }),
})

TanStack Query + Next.js SSR

// lib/api.ts
import { createFetch } from 'tanstack-fetch'
import { cookies } from 'next/headers'

export const createServerApi = async () =>
  createFetch({
    baseUrl: process.env.API_URL!,
    source: 'ssr',
    plugins: ['trace', 'ssr-forward', 'retry-idempotent'],
    incoming: async () => ({ cookie: (await cookies()).toString() }),
  })
// app/users/page.tsx — prefetch into Query cache
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query'
import { createServerApi } from '#/lib/api'
import { UsersClient } from './users-client'

const UsersPage = async () => {
  const api = await createServerApi()
  const queryClient = new QueryClient()

  await queryClient.prefetchQuery({
    queryKey: ['users'],
    queryFn: () => api.get<User[]>('/users'),
  })

  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <UsersClient />
    </HydrationBoundary>
  )
}

export default UsersPage
// users-client.tsx
'use client'

import { useQuery } from '@tanstack/react-query'
import { createFetch } from 'tanstack-fetch'

const browserApi = createFetch({
  baseUrl: process.env.NEXT_PUBLIC_API_URL,
  source: 'browser',
  credentials: 'include',
  plugins: ['trace', 'retry-idempotent'],
})

export const UsersClient = () => {
  const { data } = useQuery({
    queryKey: ['users'],
    queryFn: ({ signal }) => browserApi.get<User[]>('/users', { signal }),
  })

  return (
    <ul>
      {data?.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  )
}

SSE

Uses fetch streams (not EventSource) — Authorization, cookies, and SSR work.

Simple — onMessage

import { createFetch } from 'tanstack-fetch/sse'

const api = createFetch({
  baseUrl: 'https://api.example.com',
  plugins: ['sse-resume'],
})

const stream = api.sse<OrderEvent>('/orders/stream', {
  onMessage: (data) => {
    console.log(data) // just the payload
  },
  onError: (error) => console.error(error),
})

// later
stream.close()

React — useSse

Pass a client created from tanstack-fetch/sse:

import { createFetch } from 'tanstack-fetch/sse'
import { FetchProvider, useSse } from 'tanstack-fetch/react'

const api = createFetch({
  baseUrl: import.meta.env.VITE_API_URL,
  plugins: ['sse-resume'],
})

const App = () => (
  <FetchProvider client={api}>
    <OrdersLive />
  </FetchProvider>
)

const OrdersLive = () => {
  const { data, isConnected, error } = useSse<OrderEvent>('/orders/stream')

  if (error) return <p>Stream failed</p>
  return (
    <p>
      {isConnected ? 'Live' : 'Connecting…'} {data?.status}
    </p>
  )
}

Advanced — for await

import { createFetch } from 'tanstack-fetch/sse'

const api = createFetch({ baseUrl: 'https://api.example.com' })

for await (const event of api.sse<OrderEvent>('/orders/stream', { signal })) {
  event.event
  event.data
  event.id
}

OpenAPI CLI

npx tanstack-fetch generate --spec ./openapi.yaml --out ./src/api
import { createApi } from './api'
import { queryOptions } from '@tanstack/react-query'

const api = createApi({
  baseUrl: process.env.NEXT_PUBLIC_API_URL,
  plugins: ['trace', 'retry-idempotent'],
})

export const getUserOptions = (id: string) =>
  queryOptions({
    queryKey: ['users', id],
    queryFn: ({ signal }) => api.users.getUser({ params: { id }, signal }),
  })

Helpers

import { unwrap, unwrapAsync, isFetchError, isAbortError } from 'tanstack-fetch'

// When you already have a FetchResult
const user = unwrap(result)
const user2 = await unwrapAsync(api.get('/users/1', { throwOnError: false }))

Example

npm run example

Copy-paste apps under examples/:

| App | Focus | | --------------------------------------------- | ----------------------------------- | | basic-http | Plain get / post / FetchError | | tanstack-query | useQuery + useMutation | | auth-status | Token + 401 / 403 / 404 / 5xx | | file-upload | api.upload + progress | | sse-live | useSse live stream | | next-ssr | App Router + ssr-forward | | trpc | tRPC + createTRPCFetchClient |

API

import {
  createFetch,
  createFormData,
  createFetchError,
  isFetchError,
  isAbortError,
  unwrap,
  unwrapAsync,
  createTraceInterceptor,
  createSsrForwardInterceptor,
  createRetryIdempotentInterceptor,
  createSseResumeInterceptor,
} from 'tanstack-fetch'

| Method | Description | | ------------------------------ | -------------------------------------- | | get/post/put/patch/delete | Typed HTTP → Promise<T> | | upload(path, opts?) | Multipart / file upload → Promise<T> | | request(method, path, opts?) | Generic verb | | sse(path, { onMessage }) | Simple stream — returns { close } | | sse(path) | Advanced — for await iterable | | use / eject | Interceptors | | createFormData(fields) | Build FormData from a plain object |

FAQ

Is tanstack-fetch an official TanStack package?
No. It is an independent MIT library shaped for @tanstack/react-query.

Can I use it without React Query?
Yes. It is a plain TypeScript fetch client; React Query is optional.

Does it work with Next.js App Router SSR?
Yes. Use source: 'ssr', the ssr-forward plugin, and incoming cookies/headers.

How do I upload files?
Use api.upload({ file, fields, onUploadProgress }) or api.post with createFormData(...).

License

MIT