TanStack Start / Router + tanstack-fetch
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 queryOptions | tanstack-fetch |
Router context queryClient | Start / Router pattern |
| SSR cookies | ssr-forward |
| SPA-only Router | same pattern, no SSR plugin |
| tRPC on the same transport | tanstack-fetch/trpc |
npm install tanstack-fetch @tanstack/react-query @tanstack/react-routerMental model
Put queryClient on createRouter({ context }). Put HTTP in createFetch. Never fork “loader fetch” vs “component fetch”.
Contract that matters for Query:
- Success → return the parsed body
- HTTP failure → throw (so
isErrorworks) - Cancel → pass Query’s
signalthrough
Side-by-side
ad-hoc loader fetch (glue)
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
// 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 }),
})// 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>
),
})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):
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.
tanstack-fetchloader-only fetch
Feature matrix
| Capability | tanstack-fetch | ad-hoc loader fetch |
|---|---|---|
| Router context Query | First-class | DIY |
| ensureQueryData | Native fit | N/A |
| Same options in component | Yes | Duplicate |
| SSR cookies | ssr-forward | Manual |
| Typed errors | FetchError | DIY |
| tRPC transport | Yes | DIY |
| SSE | useSse | DIY |
Auth, status handlers, refresh
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
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
npx tanstack-fetch migrate --from fetch --framework react --write
npx tanstack-fetch doctor --dir ./srcMove loader fetch calls into queryOptions + ensureQueryData incrementally.
Suggested folder layout
src/
lib/api.ts
queries/*.ts
router.tsx # context.queryClient
routes/users.tsx # loader + component
routes/users.$id.tsxDecision tree
- Is TanStack Query your source of truth for server state?
- Yes → start with
tanstack-fetch - No → loader-only fetch may stay
- Yes → start with
- Do you need SSE with Authorization or resume?
- Yes →
tanstack-fetch/sse+useSse - No → either client works for simple REST
- Yes →
- Do other frameworks share this backend client?
- Yes → one
createFetchacross packages - No → still fine; adapters are optional
- Yes → one
Checklist
- [ ]
queryClienton router context - [ ] Shared
queryOptionsmodules - [ ] Loaders use
ensureQueryData/prefetchQuery - [ ]
createFetchwith SSR branch - [ ] Doctor clean
Try it
npm install tanstack-fetch @tanstack/react-query @tanstack/react-router
npx tanstack-fetch doctor --dir ./srcRelated
Series
Same deep-dive format for every stack:
