Integrating TanStack Router with TanStack Query: queryClient in router context, ensureQueryData/prefetchQuery in loaders, useSuspenseQuery in components, defaultPreloadStaleTime: 0, setupRouterSsrQueryIntegration for SSR dehydration/hydration and streaming, per-request QueryClient isolation.
This skill requires familiarity with both TanStack Router and TanStack Query. Read router-core and react-router first.
This skill covers coordinating TanStack Query as an external data cache with TanStack Router's loader system. The router acts as a coordinator — it triggers data fetching during navigation, while Query manages caching, background refetching, and data lifecycle.
CRITICAL: Set
defaultPreloadStaleTime: 0when using TanStack Query. Without this, Router's built-in preload cache (30s default) prevents Query from controlling data freshness.
CRITICAL: For SSR, create QueryClient inside the createRouter factory function. A module-level singleton leaks data between server requests.
// src/main.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import {
RouterProvider,
createRouter,
createRootRouteWithContext,
} from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
// Root route declares that router context includes queryClient
// (root route file creates it with createRootRouteWithContext — see below)
const queryClient = new QueryClient()
const router = createRouter({
routeTree,
defaultPreloadStaleTime: 0, // Let Query manage caching
context: { queryClient },
Wrap: ({ children }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
),
})
declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}
function App() {
return <RouterProvider router={router} />
}
// src/routes/__root.tsx
import { createRootRouteWithContext, Outlet } from '@tanstack/react-router'
import type { QueryClient } from '@tanstack/react-query'
// Double parentheses: factory pattern
export const Route = createRootRouteWithContext<{
queryClient: QueryClient
}>()({
component: () => <Outlet />,
})
// src/router.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { createRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
export function createAppRouter() {
// Fresh QueryClient per request — prevents data leaking between SSR requests
const queryClient = new QueryClient()
return createRouter({
routeTree,
defaultPreloadStaleTime: 0,
context: { queryClient },
Wrap: ({ children }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
),
})
}
declare module '@tanstack/react-router' {
interface Register {
router: ReturnType<typeof createAppRouter>
}
}
setupRouterSsrQueryIntegrationFor automatic SSR dehydration/hydration and streaming:
npm install @tanstack/react-router-ssr-query
// src/router.tsx
import { QueryClient } from '@tanstack/react-query'
import { createRouter } from '@tanstack/react-router'
import { setupRouterSsrQueryIntegration } from '@tanstack/react-router-ssr-query'
import { routeTree } from './routeTree.gen'
export function createAppRouter() {
const queryClient = new QueryClient()
const router = createRouter({
routeTree,
defaultPreloadStaleTime: 0,
context: { queryClient },
})
setupRouterSsrQueryIntegration({
router,
queryClient,
// wrapQueryClient: true (default — wraps with QueryClientProvider)
// handleRedirects: true (default — handles redirect() from queries)
})
return router
}
The integration:
redirect() thrown from queries/mutations// src/router.tsx
import { QueryClient, dehydrate, hydrate } from '@tanstack/react-query'
import { QueryClientProvider } from '@tanstack/react-query'
import { createRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
export function createAppRouter() {
const queryClient = new QueryClient()
return createRouter({
routeTree,
defaultPreloadStaleTime: 0,
context: { queryClient },
dehydrate: () => ({
queryClientState: dehydrate(queryClient),
}),
hydrate: (dehydrated) => {
hydrate(queryClient, dehydrated.queryClientState)
},
Wrap: ({ children }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
),
})
}
ensureQueryData in Loader + useSuspenseQuery in ComponentThis is the recommended pattern. The loader ensures data is in the cache before render (no loading flash). The component subscribes to the cache for updates.
// src/routes/posts.tsx
import { queryOptions, useSuspenseQuery } from '@tanstack/react-query'
import { createFileRoute } from '@tanstack/react-router'
interface Post {
id: string
title: string
}
const postsQueryOptions = queryOptions({
queryKey: ['posts'],
queryFn: (): Promise<Array<Post>> =>
fetch('/api/posts').then((r) => r.json()),
})
export const Route = createFileRoute('/posts')({
loader: ({ context }) => {
// ensureQueryData returns cached data if available, fetches if not in cache
// To also refetch stale data, pass revalidateIfStale: true
return context.queryClient.ensureQueryData(postsQueryOptions)
},
component: PostsPage,
})
function PostsPage() {
// useSuspenseQuery subscribes to cache — gets background updates
const { data: posts } = useSuspenseQuery(postsQueryOptions)
return (
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}
// src/routes/posts/$postId.tsx
import { queryOptions, useSuspenseQuery } from '@tanstack/react-query'
import { createFileRoute } from '@tanstack/react-router'
interface Post {
id: string
title: string
content: string
}
const postQueryOptions = (postId: string) =>
queryOptions({
queryKey: ['posts', postId],
queryFn: () => fetch(`/api/posts/${postId}`).then((r) => r.json()),
})
export const Route = createFileRoute('/posts/$postId')({
loader: ({ context, params }) => {
return context.queryClient.ensureQueryData(postQueryOptions(params.postId))
},
component: PostPage,
})
function PostPage() {
const { postId } = Route.useParams()
const { data: post } = useSuspenseQuery(postQueryOptions(postId))
return <article>{post.title}</article>
}
prefetchQuery (Not Awaited)For non-critical data, start the fetch without blocking navigation:
import { useQuery, useSuspenseQuery } from '@tanstack/react-query'
export const Route = createFileRoute('/dashboard')({
loader: ({ context }) => {
// Await critical data
const user = context.queryClient.ensureQueryData(userQueryOptions)
// Start non-critical fetch without awaiting — streams during SSR
context.queryClient.prefetchQuery(analyticsQueryOptions)
return user
},
component: Dashboard,
})
function Dashboard() {
// Critical: suspense (data ready immediately)
const { data: user } = useSuspenseQuery(userQueryOptions)
// Non-critical: regular query (shows loading state)
const { data: analytics, isLoading } = useQuery(analyticsQueryOptions)
return (
<div>
<h1>Welcome {user.name}</h1>
{isLoading ? <Skeleton /> : <AnalyticsChart data={analytics} />}
</div>
)
}
useQueryErrorResetBoundaryimport { useEffect } from 'react'
import { useQueryErrorResetBoundary } from '@tanstack/react-query'
import { useRouter } from '@tanstack/react-router'
export const Route = createFileRoute('/posts')({
loader: ({ context }) =>
context.queryClient.ensureQueryData(postsQueryOptions),
errorComponent: PostsErrorComponent,
component: PostsPage,
})
function PostsErrorComponent({
error,
reset,
}: {
error: Error
reset: () => void
}) {
const router = useRouter()
const queryErrorResetBoundary = useQueryErrorResetBoundary()
useEffect(() => {
queryErrorResetBoundary.reset()
}, [queryErrorResetBoundary])
return (
<div>
<p>{error.message}</p>
<button onClick={() => router.invalidate()}>Retry</button>
</div>
)
}
defaultPreloadStaleTime to 0Router has a built-in preload cache (default staleTime for preloads is 30s). This prevents Query from controlling data freshness during preloading.
// WRONG — Router's preload cache serves stale data, Query never refetches
const router = createRouter({ routeTree })
// CORRECT — disable Router's preload cache, let Query manage freshness
const router = createRouter({
routeTree,
defaultPreloadStaleTime: 0,
})
createRouter for SSRA module-level singleton QueryClient is shared across all server requests, leaking user data between requests.
// WRONG — shared across SSR requests
const queryClient = new QueryClient()
export function createAppRouter() {
return createRouter({
routeTree,
context: { queryClient },
})
}
// CORRECT — new QueryClient per createAppRouter call
export function createAppRouter() {
const queryClient = new QueryClient()
return createRouter({
routeTree,
context: { queryClient },
})
}
prefetchQuery in loader blocks renderingprefetchQuery is designed to fire-and-forget. Awaiting it blocks the navigation transition until the data resolves, defeating the purpose of streaming.
// WRONG — blocks navigation, no streaming benefit