TanStack Router and Query: preload, cache and SSR checks
When TanStack Router delegates fetching to TanStack Query, two caches can affect the same navigation. A repeated preload can stop at Router before it reaches Query, while a static Query call can reuse data even after invalidation. This guide uses controlled local experiments to explain which layer made each decision and how a fresh QueryClient changes server request ownership.
The official Router–Query composition skill supplies the source patterns. BB Skills retained its original code, tested selected native behavior with synthetic data, and recorded 57 observations. These experiments do not execute a complete AI client, use real accounts, or establish production authorization.
Identify the source and runtime versions
The retained skill comes from TanStack/router commit 663282b0ebbc494aee3595ed95867ee29d01938e. Its metadata declares a library version of 1.166.2; that is different from the package versions in this repository snapshot and from the separately installed experiment. A repository commit identifies source files, not a release of every package in the monorepo.
| Identity | Recorded value | What it establishes |
|---|---|---|
| Skill metadata | 1.166.2 | The version stated by the retained skill |
| React Router | 1.170.41 | Installed native router used in the experiment |
| Router Core | 1.171.34 | Installed core used by that router |
| SSR Query integration | 1.167.3 | Installed integration and its native callbacks |
| React Query / Query Core | 5.104.1 | The actual cache API and observer behavior tested |
| React / React DOM | 19.3.0 | Actual server renderer used for synthetic pages |
| Node / esbuild | 24.10.0 / 0.28.2 | Execution and TSX transpilation tools; not a full typecheck |
The lockfile fixes transitive dependency identities for this experiment. The package preserves 121 original repository files, including the primary skill, three required skill entrypoints, linked context and the complete MIT notice credited to Tanner Linsley. It is a selected source mirror, not the full Router checkout. Thirty literal relative targets were absent from the pinned Git tree in the expanded reference audit; this does not establish that their public website routes return 404. The primary and required entrypoint Markdown links had no missing targets.
Separate Router preloading from Query freshness
Router decides whether to run a loader again. Query then decides whether that loader's query call should fetch or reuse a cached value. Setting defaultPreloadStaleTime: 0 changes the first decision; it does not override the second one.
| Controlled case | Loader calls | Query function calls | Observed result |
|---|---|---|---|
| Router default; Query stale time 0 | 1 | 1 | The second preload reused Router's cached result |
| Router preload stale time 0; Query stale time 0 | 2 | 2 | The second preload reached the loader and fetched again |
| Router preload stale time 0; Query stale time Infinity | 2 | 1 | The loader ran again, but Query reused its fresh data |
Each case used two real native preloadRoute calls, a memory history and an independently supplied query route. The original comparison fragment does not contain that route. The preloader ran in Node with the development export condition, explicit isServer: false, and an explicit synthetic origin. No fake browser window was added. This is a native preloader experiment, not a browser hover or navigation test.
Router's installed source documents a 30-second default preload freshness window. The experiment observed two immediate preloads; it did not wait 30 seconds or measure a performance benefit. In your project, inspect the route's own preload options as well as the router default.
Distinguish static loaders from observer updates
The unchanged posts loader calls queryClient.query with staleTime: 'static'. Its first call fetched synthetic posts. A second call reused the same cache entry despite a changed response fixture. After invalidation without refetching, a third static loader call still returned the old cached value. That result explains why re-running a loader alone may leave displayed data unchanged.
A separately configured active observer behaved differently. Mounting it against the already invalidated cache fetched once; a later explicit invalidation fetched again and replaced the cached title. Those are two separate triggers. A static observer did not refetch after invalidation. Check the observer options before interpreting a loader's result as a complete freshness policy.
The original PostsPage component rendered the changed cache value with useSuspenseQuery. The dynamic route also kept ['posts', 'alpha'] and ['posts', 'beta'] as distinct keys, then reused only the matching key on a repeat call. The inputs were synthetic; there was no real API request or account-specific post data.
For a stale-data report, first name the query key, then record whether the loader ran, whether the query function ran, whether an observer was mounted, and which stale-time options applied. This makes a cache hit distinguishable from a failed refresh.
Make server request cache ownership visible
For server rendering, construct a QueryClient inside the router factory so each factory call owns a separate cache. The original safe factory produced two different client objects. A synthetic member value inserted into the first cache was absent from the second. The corrected comparison factory behaved the same way.
The original module-level singleton comparison produced two routers pointing to the same QueryClient. A value written through the first router was readable through the second. This reproduces shared cache visibility; it does not demonstrate a real authentication bypass or prove that a particular production server leaks user data.
Keep identity checks and authorization checks separate. A per-request client removes this specific shared object, but your project must still use appropriate keys, control the data included in dehydration, and enforce access at its data source. The client-only singleton shown in the basic example has a different lifetime from a singleton shared by server requests.
The manual dehydration example also round-tripped a synthetic value into another fresh client. The client objects remained distinct. This exercised its native dehydration and hydration callbacks in Node, without browser storage or a transported HTTP response.
Await critical data and inspect the actual stream
The original dashboard loader waited for the controlled user promise while starting analytics in the background. After the user promise resolved, the loader returned while analytics remained pending. The original component rendered the synthetic user's name and its loading skeleton. A later analytics rejection was caught; no unhandled rejection was recorded.
The comparison loaders made the scheduling difference visible. Awaiting optional analytics kept the loader unresolved until that query settled. The fire-and-forget variant returned immediately while its query remained pending. The critical-data variant returned a promise and waited for the required result. These observations describe event order, not a speed benchmark.
The integration example was exercised with real server SSR utilities and React server rendering. It rendered cached posts, dehydrated the initial query, and returned a native ReadableStream. A new controlled query did not produce a stream chunk while it was pending. Once it resolved, the stream supplied that query with its resolved data. Calling the native render-finished hook closed the stream; native cleanup cleared the request cache.
An initial test waited for a pending-query chunk and reached its four-second guard. That failed expectation is retained in the experiment notes. The corrected test follows the installed integration's observed timing. It does not verify HTML streaming over HTTP, client hydration, redirects, browser retry interactions, or every query scheduling pattern.
Read tutorial fragments and HTTP errors deliberately
Three original contrast blocks declare the same router, factory or root variable twice and were rejected when transpiled as whole modules. The experiment tests their unchanged alternatives separately and records the independent imports. The bare loader: comparison parses as JavaScript labels but exports no loader; the experiment supplies a clearly marked object wrapper to call each property.
Some fragments also reference a generated route tree, query options or UI components not supplied by the block itself. The runner provides those inputs separately. Transpiling a fragment does not validate its TypeScript types, generated routing configuration, or use inside a complete application.
The unchanged posts query function calls response.json() without checking response.ok. A synthetic JSON response with HTTP status 503 therefore entered its Query cache as successful data. A rejected synthetic transport promise, by contrast, propagated through the original error loader and produced an error query state. HTTP status handling and transport rejection are different cases.
For your own endpoint, decide which response statuses count as errors and validate the response data. Keep those project changes separate from the retained original snippet. The tested React server renderer escaped a synthetic script-shaped title, but that single text-rendering case is not a site-wide security audit.
Reproduce the native experiment before project tests
Review the downloaded package and its license first. From its router-query directory, use a Python 3 interpreter and the recorded Node version, then run:
npm ci --ignore-scripts --no-audit --no-fund
python examples/extract_native.py
node examples/compile_native.mjs
node examples/run_fixture.mjs
node --conditions=development examples/run_preload.mjs
Choose the Python executable available on your device if its command differs. Dependency installation contacts the free public npm registry. The native runners use synthetic response bodies and controlled promises; they require no API key, account, database or paid service. Generated modules stay under output and are excluded from the download, as are installed dependencies and binaries.
The included record has 49 main-fixture observations and 8 preloader observations. JavaScript HTTP and socket APIs were denied during execution and recorded zero connection attempts. This is not an operating-system network audit. Fresh runs generate fresh timestamps and results; the included hashes identify the original checked files.
Next test the complete matching project: real file-route generation, TypeScript checking, browser navigation and hover, error reset and retry, authorization, concurrent server requests, and the actual HTTP hydration path. The complete AI-client skill was not executed, and the resource retains runtime_tested: false.
Choose the next check for your project
Use the Vitest skill for controlled promise and cache assertions in your project. The Vite skill is useful when route-tree generation or module resolution prevents the snippets from reaching runtime. The Playwright CLI skill helps extend the checks to actual browser navigation and retry behavior while preserving a deliberately selected browser profile.
If your work also crosses Astro's server and client boundaries, the Astro contributor guide explains the source context and separate runtime constraints of that framework. Each resource has its own provenance, version and scope; passing one local experiment does not establish compatibility among all of them.
Sources: the fixed official Router–Query composition skill, its retained required entrypoints and MIT notice, and the official QueryClient reference. BB Skills wrote this original guide and independent fixture with AI assistance, checked the reported outputs, and states their limits. No framework endorsement or complete AI-client execution is claimed.