Skip to main content

compositions-router-query

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.

Jump to install

Source facts

Repository
hashintel/brunch
Last source activity
April 16, 2026 at 10:34
Detected SKILL.md language
English
Stars
7
Forks
0

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
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](../router-core/SKILL.md) and [react-router](../react-router/SKILL.md) 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) ```tsx // 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} /> } ``` ### Root Route with Context ```tsx // 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 />, }) ``` ### SSR-Safe Setup ```tsx // 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> } } ``` ### SSR with `setupRouterSsrQueryIntegration` For automatic SSR dehydration/hydration and streaming: ```bash npm install @tanstack/react-router-ssr-query ``` ```tsx // 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: - 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) ```tsx // 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> ), }) } ``` ## 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. ```tsx // 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> ) } ``` ### With Dynamic Params ```tsx // 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> } ``` ## Streaming Pattern: `prefetchQuery` (Not Awaited) For non-critical data, start the fetch without blocking navigation: ```tsx 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> ) } ``` ## Error Handling with `useQueryErrorResetBoundary` ```tsx 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. ```tsx // 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, }) ``` ### 2. HIGH: Creating QueryClient outside `createRouter` for SSR A module-level singleton `QueryClient` is shared across all server requests, leaking user data between requests. ```tsx // 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 }, }) } ``` ### 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. ```tsx // WRONG — blocks navigation, no streaming benefit loader: async ({ context }) => { await context.queryClient.prefetchQuery(analyticsQueryOptions) } // CORRECT — fire and forget for streaming loader: ({ context }) => { context.queryClient.prefetchQuery(analyticsQueryOptions) } // If you need to block (critical data), use ensureQueryData instead: 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. ```tsx // WRONG — passing options to the factory, not the returned function const rootRoute = createRootRouteWithContext<{ queryClient: QueryClient }>({ component: RootComponent, }) // CORRECT — double call: factory()({options}) 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 - [router-core-data-loading](../router-core-data-loading/SKILL.md) — built-in loader caching fundamentals - [router-core-ssr](../router-core-ssr/SKILL.md) — SSR setup for dehydration/hydration
View on GitHub