| name | compositions/router-query |
| description | 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. |
| type | composition |
| library | tanstack-router |
| library_version | 1.166.2 |
| requires | ["router-core","router-core/data-loading","react-router"] |
| sources | ["TanStack/router:docs/router/guide/external-data-loading.md","TanStack/router:docs/router/integrations/query.md"] |
TanStack Router + TanStack Query Integration
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: 0 when 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.
Setup: QueryClient in Router Context
Basic (Client-Only)
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import {
RouterProvider,
createRouter,
createRootRouteWithContext,
} from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
const queryClient = new QueryClient()
const router = createRouter({
routeTree,
defaultPreloadStaleTime: 0,
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} />
}
Root Route with Context
import { createRootRouteWithContext, Outlet } from '@tanstack/react-router'
import type { QueryClient } from '@tanstack/react-query'
export const Route = createRootRouteWithContext<{
queryClient: QueryClient
}>()({
component: () => <Outlet />,
})
SSR-Safe Setup
import { QueryClient, 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 },
Wrap: ({ children }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
),
})
}
declare module '@tanstack/react-router' {
interface Register {
router: ReturnType<typeof createAppRouter>
}
}
SSR with setupRouterSsrQueryIntegration
For automatic SSR dehydration/hydration and streaming:
npm install @tanstack/react-router-ssr-query
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,
})
return router
}
The integration:
- Dehydrates query state on the server and hydrates on the client automatically
- Streams queries that resolve during server render to the client
- Handles
redirect() thrown from queries/mutations
Manual SSR Dehydration/Hydration (Without SSR Query Package)
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>
),
})
}
Core Pattern: ensureQueryData in Loader + useSuspenseQuery in Component
This 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.
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 }) => {
return context.queryClient.ensureQueryData(postsQueryOptions)
},
component: PostsPage,
})
function PostsPage() {
const { data: posts } = useSuspenseQuery(postsQueryOptions)
return (
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}
With Dynamic Params
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>
}
Streaming Pattern: 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 }) => {
const user = context.queryClient.ensureQueryData(userQueryOptions)
context.queryClient.prefetchQuery(analyticsQueryOptions)
return user
},
component: Dashboard,
})
function Dashboard() {
const { data: user } = useSuspenseQuery(userQueryOptions)
const { data: analytics, isLoading } = useQuery(analyticsQueryOptions)
return (
<div>
<h1>Welcome {user.name}</h1>
{isLoading ? <Skeleton /> : <AnalyticsChart data={analytics} />}
</div>
)
}
Error Handling with useQueryErrorResetBoundary
import { 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>
)
}
Common Mistakes
1. HIGH: Not setting defaultPreloadStaleTime to 0
Router has a built-in preload cache (default staleTime for preloads is 30s). This prevents Query from controlling data freshness during preloading.
const router = createRouter({ routeTree })
const router = createRouter({
routeTree,
defaultPreloadStaleTime: 0,
})
2. HIGH: Creating QueryClient outside createRouter for SSR
A module-level singleton QueryClient is shared across all server requests, leaking user data between requests.
const queryClient = new QueryClient()
export function createAppRouter() {
return createRouter({
routeTree,
context: { queryClient },
})
}
export function createAppRouter() {
const queryClient = new QueryClient()
return createRouter({
routeTree,
context: { queryClient },
})
}
3. MEDIUM: Awaiting prefetchQuery in loader blocks rendering
prefetchQuery is designed to fire-and-forget. Awaiting it blocks the navigation transition until the data resolves, defeating the purpose of streaming.
loader: async ({ context }) => {
await context.queryClient.prefetchQuery(analyticsQueryOptions)
}
loader: ({ context }) => {
context.queryClient.prefetchQuery(analyticsQueryOptions)
}
loader: ({ context }) => {
return context.queryClient.ensureQueryData(criticalQueryOptions)
}
4. HIGH: Missing double parentheses on createRootRouteWithContext
createRootRouteWithContext<Type>() is a factory — it returns a function. The second call passes route options.
const rootRoute = createRootRouteWithContext<{ queryClient: QueryClient }>({
component: RootComponent,
})
const rootRoute = createRootRouteWithContext<{ queryClient: QueryClient }>()({
component: RootComponent,
})
Tension: Built-In SWR Cache vs External Cache
TanStack Router has its own SWR cache (staleTime, gcTime, defaultPreloadStaleTime). When using Query as an external cache:
- Set
defaultPreloadStaleTime: 0 to prevent Router's cache from short-circuiting Query's freshness logic
- Router's
staleTime/gcTime still apply to the loader return value. For pure Query patterns, return nothing from the loader (just ensureQueryData for the side effect) and read data exclusively from useSuspenseQuery
router.invalidate() re-runs loaders (which call ensureQueryData), but Query decides whether to actually refetch based on its own staleTime
Cross-References