TanStack Start + TanStack Query: Sharing Data Between Loaders and Components
One thing I like about TanStack Start is how naturally TanStack Router and TanStack Query fit together.
A route loader can fetch data before rendering. A component can read that data through useQuery. With shared query options, both can use the same cache instead of maintaining separate data-fetching logic.
But there is another piece to consider: the HTTP client.
How do you handle typed responses, authentication, request cancellation, SSR headers, and errors without duplicating the same logic across loaders and components?
That's where I use tanstack-fetch, an independent HTTP client designed to work with the TanStack Query mental model.
In this guide, we'll connect TanStack Router, TanStack Query, and tanstack-fetch, then look at SSR, mutations, SSE, tRPC, and migration.
The core idea
Keep three responsibilities separate:
- TanStack Router: navigation and route-level loading.
- TanStack Query: server-state caching, synchronization, and invalidation.
- tanstack-fetch: HTTP requests, typed responses, errors, and request-level behavior.
The goal is simple: don't create one fetching strategy for loaders and another for components.
1. Install the dependencies
npm install tanstack-fetch @tanstack/react-query @tanstack/react-router
2. Define reusable query options
Start by defining the query once. Both the loader and the component can reuse it.
// 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 }),
})
Notice the signal parameter.
TanStack Query provides an AbortSignal to the query function. Passing it to the HTTP client allows the request to be cancelled when appropriate.
This is one small detail that is easy to miss when implementing data fetching manually.
3. Put QueryClient in router context
The router needs access to the same QueryClient used by your React application.
// 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>
),
})
This illustrates the wiring. In a production TanStack Start application, follow the framework's SSR setup and create request-scoped server state where required rather than sharing a mutable server-side QueryClient between users.
4. Load data before rendering
Now the route loader can populate the Query cache.
// routes/users.tsx
import { useQuery } from '@tanstack/react-query'
import { createFileRoute } from '@tanstack/react-router'
import { usersQueryOptions } from '~/queries/users'
export const Route = createFileRoute('/users')({
loader: ({ context }) =>
context.queryClient.ensureQueryData(
usersQueryOptions,
),
component: UsersPage,
})
function UsersPage() {
const { data } = useQuery(usersQueryOptions)
return (
<ul>
{data?.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)
}
The loader and component share the same query definition.
ensureQueryData ensures that cached data is available, fetching it when the query has no cached result. If you also need to revalidate stale data before continuing, consider ensureQueryData with revalidateIfStale or an appropriate prefetch strategy, depending on your desired navigation behavior.
This gives you a clear separation of responsibilities: the router coordinates loading, Query owns the cache, and the component subscribes to it.
5. Configure the HTTP client for SSR
Server-side rendering introduces another concern: requests may need headers or cookies from the incoming request.
Here's a simplified client configuration:
// lib/api.ts
import { createFetch } from 'tanstack-fetch'
export const api = createFetch({
baseUrl:
typeof window === 'undefined'
? `${process.env.SSR_ORIGIN ?? ''}/api`
: '/api',
source:
typeof window === 'undefined'
? 'ssr'
: 'browser',
plugins:
typeof window === 'undefined'
? ['ssr-forward']
: [],
})
This is an illustrative configuration. Verify the ssr-forward plugin's setup against your version of tanstack-fetch and the headers available in your server request context. Forward only the headers your application needs.
For nested routes, the same pattern applies:
userQueryOptions(params.userId)
Use the resulting options in both the loader and the component. When routes have dependent data requirements, use route-level prefetching and carefully designed query dependencies to avoid unnecessary waterfalls.
6. Handle mutations with the same cache
Mutations should invalidate the same Query cache that loaders and components use.
const mutation = useMutation({
mutationFn: (input: CreateUserInput) =>
api.post<User>('/users', {
body: input,
}),
onSuccess: () =>
queryClient.invalidateQueries({
queryKey: ['users'],
}),
})
After a successful mutation, Query can refetch the affected data according to its invalidation and observer behavior.
You don't need a separate cache invalidation mechanism just because the initial request happened in a route loader.
7. Authentication and token refresh
Authentication is another place where HTTP logic tends to get duplicated.
An HTTP client can centralize token retrieval and refresh behavior rather than implementing it separately in every query function.
For example, tanstack-fetch provides a refresh-token interceptor:
import { createFetch } from 'tanstack-fetch'
import {
createRefreshTokenInterceptor,
} from 'tanstack-fetch/plugins'
const api = createFetch({
baseUrl: '/api',
getToken: () =>
localStorage.getItem('access_token'),
})
api.use(
'refresh-token',
createRefreshTokenInterceptor({
refresh: async () => {
// Refresh the access token using your
// application's refresh endpoint.
},
}),
)
The refresh implementation depends on your authentication flow and the installed package version. Configure the interceptor using the documented options for your version.
For production applications, consider concurrent refreshes, retry limits, refresh-token rotation, and what should happen when refreshing fails. Avoid using localStorage for credentials if your security model requires an HttpOnly cookie-based session.
The benefit of centralizing this behavior is that individual query functions can focus on retrieving data instead of repeating authentication logic.
8. Add live updates with Server-Sent Events
Not every screen is a good fit for request-and-response fetching.
Dashboards, notifications, and live status pages may need server-pushed updates. Browser EventSource does not support arbitrary Authorization headers, which can complicate authenticated SSE connections.
The tanstack-fetch/sse entry point provides a fetch-based alternative.
import { useSse } from 'tanstack-fetch/sse'
Use the documented useSse API in a client-side route component for the stream, while keeping initial data loading in your normal Query-based loader.
A practical division is:
- Load the initial state through a REST or tRPC query.
- Subscribe to updates through SSE.
- Update the relevant Query cache or invalidate queries when events arrive.
- Close the stream when the component no longer needs it.
A reconnect alone doesn't guarantee that no events were missed. If your application needs reliable resumption, design the server protocol around event IDs, replay, and recovery.
9. What about tRPC?
If your application already uses tRPC, you don't necessarily need a separate HTTP transport strategy for every feature.
TanStack Start has established integration patterns using createTRPCOptionsProxy and router context.
See the tanstack-fetch TanStack Start tRPC guide for the integration details.
The important architectural principle remains the same: keep transport concerns separate from query caching and route orchestration.
10. What does tanstack-fetch actually simplify?
The benefit isn't that raw fetch is bad. For a small application with a few requests, it may be all you need.
The difference becomes more relevant as an application grows.
| Concern | Raw fetch
|
tanstack-fetch |
|---|---|---|
| HTTP requests | Built in | Built-in |
| Typed application responses | Define your own conventions | Typed client API |
| HTTP error handling | Implement your own checks |
FetchError-based handling |
| Query cancellation | Pass the signal manually | Integrate through the query function |
| Token refresh | Implement your own flow | Refresh interceptor available |
| SSE | Build your own integration | Dedicated SSE entry point |
| Router and Query integration | Compose the pieces yourself | Reuse the same client in query options |
These are architectural trade-offs, not benchmark results. Your application's actual complexity will depend on the features you need and how consistently your existing code handles them.
11. Migrate incrementally
You don't have to rewrite every request to adopt this approach.
Start with one route that currently fetches data in its loader.
- Move the request into a reusable
queryOptionsdefinition. - Replace the loader request with
ensureQueryData. - Read the same query through
useQuery. - Pass Query's
signalto the HTTP request. - Move repeated HTTP behavior into your client configuration.
You can also explore the migration CLI:
npx tanstack-fetch migrate \
--from fetch \
--framework react \
--write
npx tanstack-fetch doctor --dir ./src
Review generated changes before committing them, especially around authentication, error handling, and SSR behavior.
12. Suggested project structure
A simple structure can keep the boundaries clear:
src/
lib/
api.ts
queries/
users.ts
router.tsx
routes/
users.tsx
users.$id.tsx
You can introduce additional modules for authentication, mutations, and live subscriptions as the application grows. There's no need to add abstractions before the codebase needs them.
Final thoughts
TanStack Start, TanStack Router, and TanStack Query already provide a strong foundation for server-rendered React applications.
The pattern I find most useful is to let each layer do one job:
- Router coordinates when route data is needed.
- Query manages cached server state.
- The HTTP client centralizes transport behavior.
tanstack-fetch is an independent open-source project built around that approach. It isn't an official TanStack package, and you don't need it to use TanStack Start. It is an option worth evaluating when typed HTTP requests, shared transport behavior, authentication, or SSE become recurring concerns in your application.
Explore the documentation: tanstack-fetch.
If you're using TanStack Start in production, I'd be interested to hear how you handle request-scoped Query clients, SSR authentication, and cache reuse between loaders and components.
Top comments (0)