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.tsx exports 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 → 200
A 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 requests
Why 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:'/'}) → overwrites stores.pendingMatches = [/] → yields.
- A resumes:
onReady re-reads the shared stores.pendingMatches (router.ts L2522) → commits / data → renderToString() renders the / route's data for the /about request.
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:
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 single curl passes 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 default await 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, so router.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
options fn 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) or getRouter (the start-basic-nitro example) 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
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. 🙏
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/startv2, thewith-tanstack-routertemplate'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
createHandleraccepts only(fn, options).so
routerLoadis silently ignored, the router store never gets initialized, anduseRouterState()/useStore()crashes withTypeError: Cannot read properties of undefined (reading 'state').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-routertemplate, install, andnpm run dev:curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/it returns
500(the server log shows theTypeErrorabove).CSR (browser navigation) works fine, because
createBrowserHistory()auto-initializes the router on the client.The current
entry-server.tsxcall looks like this:What I think is going on
From what I can see,
@solidjs/start@2.0.0-beta.0'screateHandleraccepts only 2 args(fn, options = {}). Reference: https://github.com/solidjs/solid-start/blob/068b64cb18/packages/start/src/server/handler.ts#L130-L135It looks like the 3rd
routerLoadarg 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'sentry-server.tsxverbatim into the v2 template, the now-deadrouterLoadcall seems to have come along with it, and templates #262 (brenelz beta upgrade, 2026-07-15) didn't touch it.Timeline:
routerLoad(2-arg createHandler, no replacement)A possible fix for the 500 (Approach A — template-only, no solid-start changes)
One nice thing about v2 is that the 2nd
optionsarg ofcreateHandleraccepts anasync (context) => HandlerOptionsfunction that isawaited before rendering. If I'm reading it right, we can simply move therouterLoadlogic in there:Diff for
entry-server.tsx:I tried this locally and it seems to work — a single
curlnow passes:A 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:Why it's unsafe on v2:
@solidjs/startv2 (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()andload()(that stretch is synchronous); it's insideload()atloadMatches→Promise.all(route loaders)(TanStackload-matches.tsL1030). Roughly:/about):update({history:'/about'})→stores.pendingMatches = [/about]→await loadMatches()yields./):update({history:'/'})→ overwritesstores.pendingMatches = [/]→ yields.onReadyre-reads the sharedstores.pendingMatches(router.ts L2522) → commits/data →renderToString()renders the/route's data for the/aboutrequest.h3's
AsyncLocalStoragecovers 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:
createRouteras a function — https://tanstack.com/router/latest/docs/framework/solid/how-to/setup-ssrstart-basic-nitroexample exports agetRouter()factory, not a singleton — https://github.com/TanStack/router/tree/main/examples/solid/start-basic-nitrocreateRequestHandlercallscreateRouter()once per request — https://github.com/TanStack/router/blob/main/packages/router-core/src/ssr/createRequestHandler.tsgetRouter()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/aboutthat returns clearly distinguishable data. Then I hit/and/aboutsimultaneously against a production build.A concrete contaminated response: a request to
/came back rendering the/aboutroute's component and loader data in full, with the same internaldata-nas the overlapping/aboutresponse — 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 3000reproduces it.Honest limits of the reproduction (please do read)
A few caveats so this isn't overstated:
router.load()and the yield window never opens. A singlecurlpasses even with the singleton — which is exactly how it slipped by me at first.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.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.)/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 theoptionsasync fn." I tried that, and it does not fix the race on its own. The reason:app.tsxbinds the router via<RouterProvider router={router} />, and that binding still reads the singleton — so the fresh router created inside theoptionsfn never actually reaches the render. The window closes only if the per-request router is passed through the request context (e.g. stashed ongetRequestEvent()and read back insideapp.tsx), not just constructed locally.I've left the diff I used as the control in
FACTORY_PATCH.diffin 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
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.createRouter(TanStack docs convention) orgetRouter(thestart-basic-nitroexample) more in keeping with this repo's style?solid-start/with-tanstack-router) get the same treatment, or is v1 considered frozen?Related
routerLoad, 2-arg createHandler: Devinxi solid-start#1942getRouter()request-scoped state leak: Start SSR: singleton getRouter() silently leaks request-scoped router state across requests - please add dev warning TanStack/router#6924If any of this sounds right, I'd be glad to open a PR — and very happy to be corrected on anything I've misread. 🙏