Repository navigation
[Bug] with-tanstack-router template: SSR returns 500 on @solidjs/start v2, and a possible router-singlelet concern #268
Description
I ran into this while poking at the v2 templates and wanted to share what I found, in case it's useful. I'm not 100% sure on every detail, so please feel free to push back on anything below.
Summary / TL;DR
on @solidjs/start v2, the with-tanstack-router template's SSR returns HTTP 500 for every route.
CSR works fine.
As far as I can tell, the cause is that entry-server.tsx calls createHandler(fn, undefined, routerLoad) with 3 arguments.
but v2's createHandler accepts only (fn, options).
so routerLoad is silently ignored, the router store never gets initialized, and useRouterState()/useStore() crashes with TypeError: Cannot read properties of undefined (reading 'state').
- I was initially unsure about but did go and reproduce: the template's
router.tsxexports a module-level singleton (export const router = createRouter()), and under concurrent SSR requests this leaks state across requests (I'll show the reproduction below). It also goes against TanStack Router's official SSR guidance (a fresh router per request).
Reproduction (the 500 is easy to reproduce)
If you create a project from the solid-start-v2/with-tanstack-router template, install, and npm run dev:
curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/it returns 500 (the server log shows the TypeError above).
CSR (browser navigation) works fine, because createBrowserHistory() auto-initializes the router on the client.
The current entry-server.tsx call looks like this:
export default createHandler(
() => <StartServer document={...} />,
undefined,
routerLoad, // appears to be silently ignored in v2
);What I think is going on
From what I can see, @solidjs/start@2.0.0-beta.0's createHandler accepts only 2 args (fn, options = {}). Reference: https://github.com/solidjs/solid-start/blob/068b64cb18/packages/start/src/server/handler.ts#L130-L135
It looks like the 3rd routerLoad arg existed in solid-start for ~8 months, then was removed in solid-start PR #1942 (Brendonovich, 2025-11-07) without a migration guide or replacement. When templates #254 copied v1's entry-server.tsx verbatim into the v2 template, the now-dead routerLoad call seems to have come along with it, and templates #262 (brenelz beta upgrade, 2026-07-15) didn't touch it.
Timeline:
| Date | Event | Actor | Link |
|---|---|---|---|
| 2025-11-07 | solid-start #1942 removed routerLoad (2-arg createHandler, no replacement) |
Brendonovich | solidjs/solid-start#1942 |
| 2026-03-17 | templates #254 "Reintegrate SolidStart v1" — copied v1 entry-server.tsx unchanged into v2 template | atilafassina | #254 |
| 2026-07-15 | templates #262 beta.0/nitro v3/vite 8 upgrade — did not touch entry-server.tsx | brenelz | #262 |
A possible fix for the 500 (Approach A — template-only, no solid-start changes)
One nice thing about v2 is that the 2nd options arg of createHandler accepts an async (context) => HandlerOptions function that is awaited before rendering. If I'm reading it right, we can simply move the routerLoad logic in there:
Diff for entry-server.tsx:
-import { createHandler, FetchEvent, StartServer } from "@solidjs/start/server";
+import { createHandler, StartServer } from "@solidjs/start/server";
import { createMemoryHistory } from "@tanstack/solid-router";
-import { router } from "./router";
+import { createRouter } from "./router"; // factory, not singleton (see Section 5)
-const routerLoad = async (event: FetchEvent) => {
- const url = new URL(event.request.url);
- const path = url.href.replace(url.origin, "");
- router.update({ history: createMemoryHistory({ initialEntries: [path] }) });
- await router.load();
-};
-
export default createHandler(
() => <StartServer document={...} />,
- undefined,
- routerLoad
+ async (context) => {
+ const url = new URL(context.request.url);
+ const path = url.href.replace(url.origin, "");
+ const router = createRouter(); // fresh per request
+ router.update({ history: createMemoryHistory({ initialEntries: [path] }) });
+ await router.load();
+ return {};
+ }
);I tried this locally and it seems to work — a single curl now passes:
$ curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/ → 200
$ curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/about → 200A second concern: the module-level router singleton leaks state under concurrency (reproduced)
I want to be upfront: I was initially unsure whether this was a real bug or just theoretical, so I went and tried to reproduce it. It reproduces — deterministically, in production mode. Details below, with the honest limits of the reproduction spelled out too.
The current router.tsx:
export const router = createRouter(); // one instance shared across all requestsWhy it's unsafe on v2: @solidjs/start v2 (Nitro/h3) serves concurrent requests on a single event loop with no per-request serialization, so two requests sharing one router instance can leak state into each other.
The yield window is not between update() and load() (that stretch is synchronous); it's inside load() at loadMatches → Promise.all(route loaders) (TanStack load-matches.ts L1030). Roughly:
- Request A (
/about):update({history:'/about'})→stores.pendingMatches = [/about]→await loadMatches()yields. - Request B (
/):update({history:'/'})→ overwritesstores.pendingMatches = [/]→ yields. - A resumes:
onReadyre-reads the sharedstores.pendingMatches(router.ts L2522) → commits/data →renderToString()renders the/route's data for the/aboutrequest.
h3's AsyncLocalStorage covers the framework's request context but not an app-exported module singleton, so cross-contamination is possible.
TanStack Router's own SSR guidance goes the other way:
- Official docs pass
createRouteras a function — https://tanstack.com/router/latest/docs/framework/solid/how-to/setup-ssr - The
start-basic-nitroexample exports agetRouter()factory, not a singleton — https://github.com/TanStack/router/tree/main/examples/solid/start-basic-nitro - TanStack's
createRequestHandlercallscreateRouter()once per request — https://github.com/TanStack/router/blob/main/packages/router-core/src/ssr/createRequestHandler.ts - Related upstream issue: Start SSR: singleton getRouter() silently leaks request-scoped router state across requests - please add dev warning TanStack/router#6924 (singleton
getRouter()leaks request-scoped state)
Reproduction (deterministic, production build)
I scaffolded the template, applied Approach A (so SSR returns 200), kept the singleton, and added a small async loader (await setTimeout(50ms)) to / and /about that returns clearly distinguishable data. Then I hit / and /about simultaneously against a production build.
| config | concurrent pairs | cross-contaminated |
|---|---|---|
| singleton (template pattern) | 15 cold-start pairs | 15 / 15 (100%) |
| singleton | 300 warm pairs | 300 / 300 (100%) |
| per-request factory (control) | 15 cold + 300 warm | 0 / 15, 0 / 300 (0%) |
A concrete contaminated response: a request to / came back rendering the /about route's component and loader data in full, with the same internal data-n as the overlapping /about response — i.e. both requests rendered the same shared router state at the same instant. Full reproducer (scripts, the factory patch used as the control, and the matrix) is at https://github.com/nerdchanii/solid-tanstack-router-ssr-race — pnpm install && pnpm build && node cold-burst-test.mjs 15 3000 reproduces it.
Honest limits of the reproduction (please do read)
A few caveats so this isn't overstated:
- The shipped demo doesn't reproduce it. The template's routes are static with no loaders, so there's nothing async inside
router.load()and the yield window never opens. A singlecurlpasses even with the singleton — which is exactly how it slipped by me at first. - Any async loader reproduces it immediately. With
await setTimeout(50ms)loaders and cold-cache concurrent requests, the defaultawait router.load()contaminates 100% of the time — no special options needed. So any real app doing data loading (fetch/ DB /createServerFn) is exposed. - Warm cache hides the race. TanStack's match cache (
defaultStaleTime: 5000) short-circuits the loader on repeat hits, sorouter.load()returns synchronously and the window closes. This is why a naive load test on the demo can look clean even though the structure is unsafe. (Cold starts and cache misses re-open it.) - Contamination direction is timing-dependent — sometimes
/renders/about's data, sometimes the reverse. Deterministic in occurrence, non-deterministic in direction.
Important: the obvious per-request fix is not enough
My first instinct was "export a createRouter() factory and call it inside the options async fn." I tried that, and it does not fix the race on its own. The reason: app.tsx binds the router via <RouterProvider router={router} />, and that binding still reads the singleton — so the fresh router created inside the options fn never actually reaches the render. The window closes only if the per-request router is passed through the request context (e.g. stashed on getRequestEvent() and read back inside app.tsx), not just constructed locally.
I've left the diff I used as the control in FACTORY_PATCH.diff in the reproducer — it's more involved than "swap the export," so I'd genuinely like the team's read on where the per-request router should live before I propose a concrete patch.
A few things I'd appreciate thoughts on
- Would it make sense to bundle both changes in one PR (two commits), or split them? My instinct is one PR, two commits — same files, and the SSR fix naturally pulls in the per-router work — but I'm happy to follow whatever the team prefers.
- Where should the per-request router live? Constructing it inside the
optionsfn isn't enough on its own (see above) — I'd value the team's steer on the right place to thread it through the request context. This is the part I'm least sure about. - For the factory naming, is
createRouter(TanStack docs convention) orgetRouter(thestart-basic-nitroexample) more in keeping with this repo's style? - A dev-mode warning for singleton routers during SSR would probably live upstream in TanStack/router (see #6924) — a bit out of scope here, but I thought I'd mention it.
- Should the v1 template (
solid-start/with-tanstack-router) get the same treatment, or is v1 considered frozen?
Related
- templates reintegrate SolidStart v1 #254 — copied v1 entry-server.tsx into v2 unchanged: reintegrate SolidStart v1 #254
- templates Update solid-start-v2 templates to @solidjs/start 2.0.0-beta.0 and nitro v3 #262 — v2 beta upgrade (did not touch entry-server.tsx): Update solid-start-v2 templates to @solidjs/start 2.0.0-beta.0 and nitro v3 #262
- templates Type error when generating project with TanStack Router #237 — separate open issue (TypeScript error on template generation); not the same bug but possibly related housekeeping: Type error when generating project with TanStack Router #237
- solid-start #1942 — removed
routerLoad, 2-arg createHandler: Devinxi solid-start#1942 - TanStack/router #6924 — singleton
getRouter()request-scoped state leak: Start SSR: singleton getRouter() silently leaks request-scoped router state across requests - please add dev warning TanStack/router#6924 - TanStack Router SSR setup docs: https://tanstack.com/router/latest/docs/framework/solid/how-to-setup-ssr
If any of this sounds right, I'd be glad to open a PR — and very happy to be corrected on anything I've misread. 🙏
Activity
birkskyum commentedon Aug 4, 2026
@nerdchanii , this should be fixed, can you try running this template??
https://github.com/solidjs/templates/tree/main/solid-start-v2/with-tanstack-router
nerdchanii commentedon Aug 5, 2026
Yeah, I just gave it a try!
The
500 Erroris fixed. On@solidjs/start@2.0.0stable, both/and/aboutreturn 200 in dev and prod. But, the underlying structural concern remains: a module-level singleton router.The template currently exports the router as a module singleton:
what is matter
Request-scoped state should not be shared across SSR requests.
A router instance holds its cache and fetched loader data — sharing it across requests creates a high risk of cross-user data leakage. "It doesn't leak in simple cases" is just a symptom hidden by cache timing rather than a security guarantee. The proper pattern is a per-request router instance, as recommended in the TanStack SSR guide and the start-basic-nitro example.
The leak is deterministic, not just a race condition
I extended the reproduction repo with a /secret route whose loader returns user-scoped data based on the x-user-id request header.
Two distinct failure modes:
Mode 1 — Cold-start concurrent race:
When Alice and Bob send requests simultaneously on a cold cache, Bob's response renders SECRET-TOKEN-FOR-alice. leak-test.mjs reports 15/15 leaks (100%, exit 2).
Mode 2 — Warm sequential leak (No concurrency, 100% deterministic):
This is the critical signal. Alice accesses /secret first. Every subsequent user (Bob, Carol, Dave) sequentially receives Alice's secret in their rendered HTML. The router caches the /secret match using only the route path as the key (not route + user), serving the first user's cached data to everyone. This reproduces one request at a time without any race condition.
Reproduction Steps:
Repro: https://github.com/nerdchanii/solid-tanstack-router-ssr-race (on stable 2.0.0)
Honest caveat
When the cache is warm, sequential requests to different routes do not cross-contaminate. However, cross-user leakage on the same route is 100% reproducible even sequentially because the cache key relies solely on the route path.
Suggested fix
Create the router instance per request (following the TanStack SSR guide and start-basic-nitro). If you could point me to where this should be threaded in @solidjs/start's handler wiring, I'd be happy to open a PR with a per-request implementation!
nerdchanii commentedon Aug 6, 2026
@birkskyum If you wanna I will open the PR.
Add verification: per-request router eliminates the SSR leak