ADR-004: SSR Data Availability in shellComponent and Safe Prefetching Patterns
Establishes correct patterns for passing server-read data into the TanStack Start shell, and rules out unsafe SSR prefetching via ensureQueryData in beforeLoad.
Status
Accepted
Context
A persistent pattern of E2E CI failures was traced to two independent architectural misunderstandings of TanStack Start's SSR rendering pipeline:
-
ConsentProvider received a permanently-null
initialConsentprop on every SSR render. The consent cookie was read in the root routeloader, but the component that consumed it (AppShell, which rendersConsentProvider) lives insideshellComponent. TanStack Start rendersshellComponentas the outer HTML wrapper before loader data is available.Route.useLoaderData()called insideshellComponentalways returnsundefinedat SSR time, soserverConsentwas alwaysnull.computeShowBanner(null)always returnstrue, meaning the consent banner always rendered on first paint. A client-sideuseEffectsubsequently read the cookie fromdocument.cookieand hid the banner, but the brief window between SSR render and hydration was enough for Playwright's interaction actions (e.g. clicking the Save button on the profile page) to be intercepted by the banner overlay. The failure was non-deterministic: it occurred most often on mobile viewports (smaller click targets) and slow CI shards. -
ensureQueryDatacalled inbeforeLoadduring SSR issued unauthenticated fetch requests. WhenbeforeLoadruns server-side, cookies from the browser are not forwarded to outboundfetch()calls unless explicitly threaded through. The TanStack Query fetcher functions used in route files called relative URLs (e.g./api/admin/feature-flags) which, in a Node.js SSR context, resolve tolocalhost:4000/api/admin/feature-flagswithout a cookie header. The API returned 401. TanStack Query's default retry logic then issued up to 3 retries, delaying thenetworkidleevent and causing test timeouts.
A third, compounding issue was discovered during this analysis:
waitForLoadState('networkidle')is unusable as a general readiness signal in any environment where TanStack Query is active. Background refetches, stale-while-revalidate cycles, and retry delays make the network never truly idle in a long-lived React app. This was masked in CI (production build, no HMR) becausenetworkidlesettled quickly after the initial page load, but it created a latent fragility that any newuseQuerycall can reactivate.
Related files investigated:
apps/web/src/routes/__root.tsx— shellComponent / loader placementapps/web/src/lib/consent/consentProvider.tsx— ConsentProvider state initializationapps/web/src/lib/consent/server.ts—getServerConsent(createServerFn)apps/web/src/routes/admin/feature-flags.tsx— removedensureQueryDatafrombeforeLoadapps/web/e2e/auth.setup.ts— consent cookie injection viaaddCookies
TanStack Start rendering lifecycle (summary)
SSR render order:
1. shellComponent renders (html > head > body wrapper)
→ Route.useLoaderData() is undefined here
→ beforeLoad context IS available here via useRouteContext()
2. loader runs (concurrent with or after shellComponent)
3. route component renders (loader data IS available)
4. HTML sent to client
5. React hydrates
6. useEffect hooks run (client-only)The critical invariant: loader data is never available inside shellComponent. This is
by design in TanStack Start — the shell is the document skeleton, rendered before any data
fetch completes.
Options Considered
Option A: Move ConsentProvider from shellComponent to root component
Move AppShell (which contains ConsentProvider) out of RootDocument (the
shellComponent) and into the root route's component. Route component renders after
loader data is available, so Route.useLoaderData() returns the correct value.
- Pros:
- Simplest mechanical change: move one function call from
shellComponenttocomponent Route.useLoaderData()works correctly incomponent— no API change- No new concepts introduced; conforms to the framework's intended usage
ConsentProvideralready handles the CSR fallback (readCookieClient()) so there is no regression for routes that do not go through the root loader
- Simplest mechanical change: move one function call from
- Cons:
- The
<html>,<head>, and<body>tags remain inshellComponentwhile the consent banner lives incomponent; they are rendered in two distinct tree positions at SSR time. This is the correct TanStack Start model but can be surprising to readers. - Any future data placed in
loaderthat is needed by the shell HTML (e.g. alangattribute that depends on per-user preferences) still cannot be sourced from the loader and must usebeforeLoadcontext instead.
- The
Option B: Move server consent read from loader to beforeLoad; pass via context
Read the consent cookie in beforeLoad using getServerConsent() (a createServerFn).
Return { serverConsent } from beforeLoad, which merges into the router context available
as ctx.context in downstream beforeLoad calls and, crucially, is accessible via
useRouteContext() inside shellComponent.
- Pros:
beforeLoadcontext IS available inshellComponent— this is the documented mechanism for passing server-side data into the shell- Enables true zero-flash SSR for the consent banner: the cookie is read, parsed, and injected into the React tree before the first pixel is painted
- Consistent with how the session (
getServerEnrichedSession) is already threaded throughbeforeLoadcontext in the root route - The pattern is composable: any server-read cookie that affects shell rendering can follow the same pattern
- Cons:
beforeLoadruns for every navigation (client and server), not just initial SSR. The server function call is cheap (cookie read + parse), but it does run on every route change. In practice,createServerFnwithmethod: 'GET'is a lightweight RPC; this is acceptable.- Requires updating
MyRouterContextto includeserverConsent, then reading it inAppShellviauseRouteContext()instead ofuseLoaderData(). Minor but a real change. - If
beforeLoadthrows, the shell fails to render. The consent read is non-critical; it must be wrapped in try/catch and default tonullon error.
Option C: Inject consent via Nitro middleware before SSR (meta tag or global)
In apps/web/src/server.ts, read the consent cookie in the Nitro/H3 request handler
before delegating to the TanStack Start handler. Serialize the consent value into a
<meta> tag injected into the <head>, or pass it as a header that the SSR runtime picks
up via getRequestHeader.
- Pros:
- Consent data available at the very first byte of SSR output, with zero round-trips
- Does not touch TanStack Router's lifecycle at all
- Cons:
- Requires custom Nitro middleware plumbing (
app.use, H3 event manipulation) - Meta-tag injection requires a
document.querySelectorread on the client side, which is fragile (timing, CSP, SSR mismatch) - The existing
createServerFnapproach (getServerConsent) is already correct and simple; this option replaces it with lower-level plumbing for no material gain - Couples cookie-reading logic to the Nitro layer rather than keeping it in TanStack Router's data model
- Requires custom Nitro middleware plumbing (
Option D: Keep loader; add a client-side synchronous cookie read before first render
Add synchronous document.cookie parsing in ConsentProvider initial state computation
(before any useState initializer or useEffect). Since this runs both server-side
(returns null — document is undefined in Node) and client-side (returns the real
cookie), the client would compute the correct initial state during React hydration.
- Pros:
- No change to routing lifecycle
- The
readCookieClient()function already exists; it just needs to be called earlier
- Cons:
- Does not fix the SSR render path: the server still sends
showBanner: trueHTML. React hydrates withshowBanner: false(from the client read), causing a hydration mismatch. React suppresses these mismatches withsuppressHydrationWarningbut the behavior is undefined for dynamic state. - The banner flash still occurs: the server sends visible-banner HTML; the browser renders it; React hydrates and hides it. The race window with Playwright is smaller but not eliminated.
- Does not address the architectural root cause; trades a correctness problem for a hydration warning.
- Does not fix the SSR render path: the server still sends
Decision
Adopt Option B (move consent read to beforeLoad, pass via router context) as the
canonical pattern for all shell-level server data in this codebase.
Rationale
Option B is preferred over Option A because it eliminates the banner flash entirely rather
than merely reducing it. When consent is read in beforeLoad, the value is available
before shellComponent renders, so ConsentProvider receives the correct initialConsent
during the SSR pass. The resulting HTML does not include a visible banner for users who
have already consented. Option A (move to component) still emits SSR HTML with
showBanner: true because the loader runs concurrently with, not before, the shell render;
it fixes the Playwright race but not the UX flash.
Option B is also the idiomatic TanStack Start pattern. The project already uses it for the
session: getServerEnrichedSession is called in beforeLoad and the result is threaded
into ctx.context.session, which is then read by downstream beforeLoad guards
(enforceRoutePermission) and by shellComponent via useRouteContext. Consent should
follow the same pattern for consistency.
Safe prefetching pattern for SSR (addresses Root Cause #2)
ensureQueryData in beforeLoad is unsafe during SSR because:
- TanStack Query's
queryFnfunctions use relative URLs (/api/...) that are resolved againstlocalhostin the Node.js context. - Outbound server-side
fetch()does not automatically forward the browser's cookies. - The resulting 401 triggers TanStack Query retries, which delay page load.
The correct patterns are:
-
For data that must be available before first render: Read it in
beforeLoadusing acreateServerFnthat explicitly callsgetRequestHeader('cookie')and forwards cookies to the NestJS API (identical pattern togetServerEnrichedSession). Return the data frombeforeLoadso it enters the router context. -
For data that improves time-to-content but is not critical: Use TanStack Router's
loader(notbeforeLoad) with TanStack Query'sensureQueryData. Theloaderruns afterbeforeLoadhas completed and its return value is NOT available inshellComponent. ThequeryFninside the loader must use a server function that forwards cookies, not a plainfetchcall. -
For data fetched purely client-side: Use
useQueryin components with no prefetching. This is the default and is always safe.
The pattern to avoid:
// UNSAFE: ensureQueryData in beforeLoad with a plain fetch queryFn
beforeLoad: async (ctx) => {
await enforceRoutePermission(ctx)
await ctx.context.queryClient.ensureQueryData(someQuery.list()) // <- DO NOT DO THIS
},The replacement pattern (loader with cookie-forwarding server function):
// SAFE: prefetch in loader using a server function that forwards cookies
loader: async ({ context }) => {
await context.queryClient.prefetchQuery({
queryKey: someQueryKeys.list(),
queryFn: () => fetchSomeListServerFn(), // createServerFn that forwards cookies
})
},waitForLoadState('networkidle') prohibition (addresses Root Cause #4)
waitForLoadState('networkidle') MUST NOT be used in E2E tests in this codebase. The
correct readiness signals are:
page.waitForURL(pattern)— confirms navigation completedpage.waitForLoadState('domcontentloaded')— confirms DOM is parseableexpect(locator).toBeVisible()/locator.waitFor()— confirms the specific element the test cares about is renderedpage.waitForSelector('form')— confirms a key structural element is present
The networkidle signal is unreliable whenever TanStack Query is mounted: background
refetches, stale-while-revalidate, retry delays, and dev-mode HMR websockets all prevent
the network from reaching the 500ms idle threshold that Playwright requires.
Consequences
Positive
- Zero-flash consent banner: SSR HTML is rendered with the correct initial consent state. Users who have already accepted consent never see the banner, not even for one frame. This is a meaningful UX improvement.
- E2E test stability: The primary cause of the
[mobile-chrome] profile.spec.tsfailures (banner intercepting Save button click) is eliminated at the root, not patched. - Consistent server-data pattern:
beforeLoad+createServerFn+useRouteContextbecomes the single canonical pattern for all shell-level server data. The session already uses this; consent follows. - SSR prefetching is safe: The loader-based prefetch pattern with cookie-forwarding server functions prevents 401 errors and their associated retry storms.
networkidleprohibition eliminates a class of flaky tests that pass in CI (production build) but fail locally (dev mode), masking real issues.
Negative
MyRouterContextwidens: AddingserverConsentto the root context means every route'sbeforeLoadcontext carries this field. It is cheap (a small JSON object or null) but it is a permanent addition to the context shape.beforeLoadruns on every navigation:getServerConsent(a cookie read + Zod parse) is called on every client-side navigation, not just the initial SSR. The cost is negligible but it is a behavioral difference from the loader approach.- Pattern requires documentation: The two-tier distinction (shellComponent data via beforeLoad context vs. route component data via loader) is non-obvious. New contributors must read this ADR before adding data to the root route.
Neutral
loaderis not removed from__root.tsx: Theloaderremains valid for data consumed in the root route'scomponent(notshellComponent). If no such data exists, the loader may be removed as a cleanup step; this ADR does not mandate it.- Test setup
addCookiescall inauth.setup.tsis correct: The Playwright setup correctly injects the consent cookie intostorageStateviapage.context().addCookies(). This ensures the cookie is present in the browser's cookie jar for all downstream tests, which meansgetServerConsent(inbeforeLoad) will read the correct value on the first SSR call. No change to the setup file is needed.
Patterns to Avoid — Summary
| Pattern | Why it fails | Correct alternative |
|---|---|---|
Route.useLoaderData() inside shellComponent | Always undefined during SSR | useRouteContext() reading data placed in beforeLoad return value |
ensureQueryData in beforeLoad | Runs SSR without forwarded cookies → 401 → retry storm | prefetchQuery in loader using createServerFn that forwards cookies |
waitForLoadState('networkidle') in E2E | TanStack Query prevents idle | waitForLoadState('domcontentloaded') + expect(locator).toBeVisible() |
ConsentProvider initialised from loader data | Banner always shows on first SSR render | Read cookie in beforeLoad, pass via context to ConsentProvider |
Plain fetch('/api/...') inside a server-side queryFn | Relative URL + no cookie → 401 | createServerFn that calls getRequestHeader('cookie') and forwards it |