Skip to content

TanStack Start / Router + tanstack-fetch ​

October 12, 2026 · TanStack Start · Router · Query

Stack

TanStack Start · @tanstack/react-router · @tanstack/react-query · tanstack-fetch

TanStack Start shines when Router + Query share context: loaders call ensureQueryData, components call useQuery with the same queryOptions.

The missing piece is a fetch client that already speaks Query’s language. tanstack-fetch is that client — with ssr-forward on the server and the same options in the browser.

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
Loaders + components share queryOptionstanstack-fetch
Router context queryClientStart / Router pattern
SSR cookiesssr-forward
SPA-only Routersame pattern, no SSR plugin
tRPC on the same transporttanstack-fetch/trpc
bash
npm install tanstack-fetch @tanstack/react-query @tanstack/react-router

Mental model ​

Put queryClient on createRouter({ context }). Put HTTP in createFetch. Never fork “loader fetch” vs “component fetch”.

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 ​

ad-hoc loader fetch (glue) ​

ts
export const Route = createFileRoute('/users')({
  loader: async () => {
    const res = await fetch('/api/users')
    return res.json()
  },
  component: ({ useLoaderData }) => {
    const data = useLoaderData()
    // separate from useQuery cache — duplicate requests later
  },
})

queryOptions + ensureQueryData ​

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

export const usersQueryOptions = queryOptions({
  queryKey: ['users'],
  queryFn: ({ signal }) => api.get<User[]>('/users', { signal }),
})
ts
// router.tsx
import { createRouter } from '@tanstack/react-router'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'

export const queryClient = new QueryClient()
export const router = createRouter({
  routeTree,
  context: { queryClient },
  Wrap: ({ children }) => (
    <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
  ),
})
ts
export const Route = createFileRoute('/users')({
  loader: ({ context }) => context.queryClient.ensureQueryData(usersQueryOptions),
  component: () => {
    const { data } = useQuery(usersQueryOptions)
    return <ul>{data?.map((u) => <li key={u.id}>{u.name}</li>)}</ul>
  },
})

Bootstrap / wiring ​

SSR api (Start):

ts
export const api = createFetch({
  baseUrl: process.env.SSR_ORIGIN ? `${process.env.SSR_ORIGIN}/api` : '/api',
  source: typeof window === 'undefined' ? 'ssr' : 'browser',
  plugins: typeof window === 'undefined' ? ['ssr-forward'] : [],
})

Nested routes: userQueryOptions(params.userId) in loader + useQuery in component. Prefetch in beforeLoad when you want waterfalls avoided.

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-fetchad-hoc loader fetch
Router context QueryFirst-classDIY
ensureQueryDataNative fitN/A
Same options in componentYesDuplicate
SSR cookiesssr-forwardManual
Typed errorsFetchErrorDIY
tRPC transportYesDIY
SSEuseSseDIY

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 ​

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

Invalidations stay on the same queryClient from router context.

Live data: SSE ​

Browser EventSource cannot set Authorization. tanstack-fetch/sse streams over fetch.

Use useSse in route components (browser). Loaders stay for initial REST/tRPC; streams are client-only.

Status: connecting | connected | disconnected | error.

tRPC over the same client ​

Recommended Start pattern: tRPC · TanStack Start with createTRPCOptionsProxy and router context.

Migrate ​

bash
npx tanstack-fetch migrate --from fetch --framework react --write
npx tanstack-fetch doctor --dir ./src

Move loader fetch calls into queryOptions + ensureQueryData incrementally.

Suggested folder layout ​

text
src/
  lib/api.ts
  queries/*.ts
  router.tsx          # context.queryClient
  routes/users.tsx    # loader + component
  routes/users.$id.tsx

Decision tree ​

  1. Is TanStack Query your source of truth for server state?
    • Yes → start with tanstack-fetch
    • No → loader-only 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 ​

  • [ ] queryClient on router context
  • [ ] Shared queryOptions modules
  • [ ] Loaders use ensureQueryData / prefetchQuery
  • [ ] createFetch with SSR branch
  • [ ] Doctor clean

Try it ​

bash
npm install tanstack-fetch @tanstack/react-query @tanstack/react-router
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