Which project does this relate to?
Router
Describe the bug
Summary
On Vite 8 (including but not limited to Vite+, the new Vite-based toolchain by VoidZero), the TanStack Start dev server plugin fails to register its SSR middleware. The result is the request falls through to the underlying Connect/Express default and the browser sees a plain 404 Not Found with body Cannot GET /. The failure is silent — no log line, no error overlay.
If the user opts in via tanstackStart({ vite: { installDevServerMiddleware: true } }) to bypass the silent skip, the next line throws:
Error: cannot install vite dev server middleware for TanStack Start since the SSR environment is not a RunnableDevEnvironment
…even though the SSR environment is in fact a RunnableDevEnvironment. Two independent bugs in @tanstack/start-plugin-core/dist/esm/vite/dev-server-plugin/plugin.js are responsible. This issue proposes a code-level fix in the plugin (not a node_modules patch).
Environment
| Package |
Version |
@tanstack/react-start |
1.168.25 |
@tanstack/start-plugin-core |
1.171.17 |
@tanstack/router-core |
1.171.13 |
@voidzero-dev/vite-plus-core (aliased vite) |
0.1.24 |
| Vite (bundled inside vite-plus-core) |
8.0.14 |
| Node |
>=20 |
| Package manager |
pnpm (workspace) |
The trigger is Vite 8 (the rest is incidental). Any project that ends up on Vite 8 with a monorepo / dual-vite-resolution layout will hit this. The user-reported case is Vite+, but the underlying cause is generic to Vite 8.
Reproduction
Minimal monorepo layout:
apps/web/
package.json // "vite": "^8.0.0" in devDeps, no extra config
vite.config.ts // plugins: [tanstackStart(), react()]
src/
router.tsx // exports getRouter() with createRouter + routeTree
routes/
__root.tsx // createRootRouteWithContext
index.tsx // createFileRoute("/")
src/ // no other entry files
// apps/web/vite.config.ts
import { defineConfig } from "vite-plus"
import { tanstackStart } from "@tanstack/react-start/plugin/vite"
import viteReact from "@vitejs/plugin-react"
export default defineConfig({
plugins: [tanstackStart(), viteReact()],
})
cd apps/web
vp run dev # or: vite dev
# ➜ Local: http://localhost:3000/
curl -i http://localhost:3000/
# HTTP/1.1 404 Not Found
# Content-Type: text/html; charset=utf-8
# Content-Length: 139
#
# <!DOCTYPE html>
# <html lang="en"><head><meta charset="utf-8"><title>Error</title></head>
# <body><pre>Cannot GET /</pre></body></html>
No error in the dev server log. No Vite error overlay. The TanStack Start server entry is never invoked.
Expected vs Actual
|
Expected |
Actual |
curl http://localhost:3000/ |
200 OK with SSR-rendered HTML of routes/index.tsx |
404 Not Found, body Cannot GET / |
| Server log |
tanstack-start: dev server listening (or similar) — middleware registered |
No TanStack Start log line; only Vite's default request log |
Root cause
Both bugs live in @tanstack/start-plugin-core/dist/esm/vite/dev-server-plugin/plugin.js (the configureServer post-hook that registers the catch-all SSR middleware).
Bug 1 — Overly broad early-return condition
// lines 55–59 (current)
if (installMiddleware == void 0) {
if (viteDevServer.config.server.middlewareMode) return;
if (!isRunnableDevEnvironment(serverEnv) || "dispatchFetch" in serverEnv) return;
}
The intent of "dispatchFetch" in serverEnv was to opt out for the new FetchableDevEnvironment, which talks to the server via dispatchFetch instead of runner.import. But in Vite 8, RunnableDevEnvironment also has a dispatchFetch method — it inherits it from the DevEnvironment base to satisfy the Environment interface contract. The RunnableDevEnvironment class declaration in vite/dist/node/index.d.ts:
declare class RunnableDevEnvironment extends DevEnvironment {
// ...
dispatchFetch(request: Request): Promise<Response>;
}
So "dispatchFetch" in serverEnv is true for every dev environment, and the post-hook unconditionally returns. The middleware is never registered.
Verified by adding a configureServer debug hook in the user's vite.config.ts:
=== DEBUG ENV ===
typeof ssr: object
constructor.name: RunnableDevEnvironment
has dispatchFetch: false ← ← ← Runnable, no dispatchFetch on the INSTANCE
has runner: true
server.middlewareMode: false
=== END DEBUG ===
(The "dispatchFetch" in serverEnv check was matching the property on the class at plugin load time, not the instance the dev server actually created.)
Bug 2 — instanceof across ESM module boundaries
// line 65 (current)
if (!isRunnableDevEnvironment(serverEnv)) throw new Error(
"cannot install vite dev server middleware for TanStack Start since the SSR environment is not a RunnableDevEnvironment"
);
isRunnableDevEnvironment(env) is implemented as env instanceof RunnableDevEnvironment, where RunnableDevEnvironment is imported by the plugin from "vite". In a monorepo (or any setup where vite is aliased / re-exported under multiple module specifiers), Node's ESM module cache can end up with two distinct RunnableDevEnvironment class identities:
- The one Vite+'s bundled Vite uses to construct the SSR env (
new RunnableDevEnvironment(...) in vite-plus-core/dist/vite/node/chunks/node.js).
- The one
@tanstack/start-plugin-core imported at module load time, bound inside the closure of isRunnableDevEnvironment.
The two classes share the same source but live in different module-graph entries. env instanceof RunnableDevEnvironment is therefore false even though env.constructor.name === "RunnableDevEnvironment".
This is the failure that becomes visible only after the user sets installDevServerMiddleware: true to skip Bug 1's early-return:
Error: cannot install vite dev server middleware for TanStack Start since
the SSR environment is not a RunnableDevEnvironment
Verified the diagnosis end-to-end by registering a custom configureServer post-hook that does the same serverRunner.import("virtual:tanstack-start-server-entry") call as the plugin would, with no instanceof check — the page renders correctly (200 OK, body contains the expected SSR markup). This isolates the failure to the plugin's gating logic, not to the underlying import / SSR pipeline.
Suggested fix
In @tanstack/start-plugin-core/dist/esm/vite/dev-server-plugin/plugin.js, replace the two identity-based guards with a feature check (the middleware only ever uses serverEnv.runner.import, so check exactly that). This avoids both the instanceof cross-module problem and the dispatchFetch false positive:
--- a/src/vite/dev-server-plugin/plugin.ts
+++ b/src/vite/dev-server-plugin/plugin.ts
@@
+// True iff `env` exposes the runner.import API this middleware relies on.
+// Feature check (not instanceof) to survive ESM module-graph boundaries
+// in monorepos and to avoid false negatives on Vite 8 where
+// RunnableDevEnvironment also exposes a `dispatchFetch` method.
+function supportsRunnerImport(env: unknown): env is { runner: { import: (id: string) => Promise<unknown> } } {
+ return (
+ typeof env === "object" &&
+ env !== null &&
+ "runner" in env &&
+ typeof (env as any).runner === "object" &&
+ (env as any).runner !== null &&
+ typeof (env as any).runner.import === "function"
+ );
+}
+
return [{
name: "tanstack-start-core:dev-server",
/* ... */
configureServer(viteDevServer) {
/* ... */
return () => {
const serverEnv = viteDevServer.environments[VITE_ENVIRONMENT_NAMES.server];
if (!serverEnv) throw new Error(`Server environment ${VITE_ENVIRONMENT_NAMES.server} not found`);
const clientEnv = viteDevServer.environments[VITE_ENVIRONMENT_NAMES.client];
const installMiddleware = installDevServerMiddleware;
if (installMiddleware === false) return;
if (installMiddleware == void 0) {
if (viteDevServer.config.server.middlewareMode) return;
- if (!isRunnableDevEnvironment(serverEnv) || "dispatchFetch" in serverEnv) return;
+ if (!supportsRunnerImport(serverEnv)) return;
}
- if (!isRunnableDevEnvironment(serverEnv)) throw new Error("cannot install vite dev server middleware for TanStack Start since the SSR environment is not a RunnableDevEnvironment");
+ if (!supportsRunnerImport(serverEnv)) throw new Error(
+ "cannot install vite dev server middleware for TanStack Start: the SSR environment does not expose runner.import(). " +
+ "This middleware requires a RunnableDevEnvironment (or equivalent)."
+ );
viteDevServer.middlewares.use(async (req, res) => { /* unchanged */ });
};
}
}];
The import { isRunnableDevEnvironment } from "vite" line can be removed (it's only used in the two checks above). Same for the import { ... } from "vite" if it was only there for that symbol.
Why this is the right shape
- Feature check, not type check. The middleware only ever calls
serverEnv.runner.import(ENTRY_POINTS.server). The only thing we need to know is "can I call .import on it?". That's a property check, not an identity check.
- Cross-module safe. A feature check is robust to class identity mismatches across ESM module-graph boundaries (the root cause of Bug 2).
- Vite 8 safe. It does not look at
dispatchFetch at all, so the RunnableDevEnvironment-now-inherits-dispatchFetch behavior in Vite 8 (Bug 1) is irrelevant.
- Forward compatible. If Vite later adds a third dev-env flavor that also exposes
runner.import, this code keeps working.
Suggested follow-up
Once the check is fixed, the installDevServerMiddleware option loses most of its value (it was only ever a workaround for this exact bug). Two options:
- Deprecate it in a minor release, document the migration, remove in the next major.
- Keep it as a "force" override that skips the feature check entirely (useful for custom SSR transports that do their own thing outside the Vite dev server). This is essentially the current behavior — just fix the bug behind it.
I'd vote for (1) + (2) combined: keep the option but redefine its semantics as "skip the check", deprecate the old behavior, document in the changelog.
Workaround (until fixed)
Pin Vite to v7. Vite 7's RunnableDevEnvironment does not expose dispatchFetch (the method was added in Vite 8), so Bug 1's check is false and the middleware is installed normally. Bug 2 may still bite in monorepos on Vite 7 — switching to a single Vite resolution (one Vite in the lockfile, no alias) is enough to dodge it.
For projects that must stay on Vite 8 / Vite+, the only currently-available workaround is patching node_modules/@tanstack/start-plugin-core/dist/esm/vite/dev-server-plugin/plugin.js. I would not recommend that for production.
Related
pnpm override setup: vite: npm:@voidzero-dev/vite-plus-core@latest + overrides: { vite: "catalog:" } to ensure a single Vite resolution across the workspace. This is the recommended configuration for Vite+ monorepos and is what surfaces the cross-module instanceof issue.
@tanstack/router-core v1.171.x ships a tanstack-router/skills/router-core/SKILL.md family. A skill for "TanStack Start dev server plugin" covering the environment-type contract would be a good add.
Checklist
Complete minimal reproducer
null
Steps to Reproduce the Bug
null
Expected behavior
null
Screenshots or Videos
No response
Platform
- Router / Start Version: [e.g. 1.121.0]
- OS: [e.g. macOS, Windows, Linux]
- Browser: [e.g. Chrome, Safari, Firefox]
- Browser Version: [e.g. 91.1]
- Bundler: [e.g. vite]
- Bundler Version: [e.g. 7.0.0]
Additional context
No response
Which project does this relate to?
Router
Describe the bug
Summary
On Vite 8 (including but not limited to Vite+, the new Vite-based toolchain by VoidZero), the TanStack Start dev server plugin fails to register its SSR middleware. The result is the request falls through to the underlying Connect/Express default and the browser sees a plain
404 Not Foundwith bodyCannot GET /. The failure is silent — no log line, no error overlay.If the user opts in via
tanstackStart({ vite: { installDevServerMiddleware: true } })to bypass the silent skip, the next line throws:…even though the SSR environment is in fact a
RunnableDevEnvironment. Two independent bugs in@tanstack/start-plugin-core/dist/esm/vite/dev-server-plugin/plugin.jsare responsible. This issue proposes a code-level fix in the plugin (not anode_modulespatch).Environment
@tanstack/react-start1.168.25@tanstack/start-plugin-core1.171.17@tanstack/router-core1.171.13@voidzero-dev/vite-plus-core(aliasedvite)0.1.248.0.14>=20The trigger is Vite 8 (the rest is incidental). Any project that ends up on Vite 8 with a monorepo / dual-vite-resolution layout will hit this. The user-reported case is Vite+, but the underlying cause is generic to Vite 8.
Reproduction
Minimal monorepo layout:
No error in the dev server log. No Vite error overlay. The TanStack Start server entry is never invoked.
Expected vs Actual
curl http://localhost:3000/200 OKwith SSR-rendered HTML ofroutes/index.tsx404 Not Found, bodyCannot GET /tanstack-start: dev server listening(or similar) — middleware registeredRoot cause
Both bugs live in
@tanstack/start-plugin-core/dist/esm/vite/dev-server-plugin/plugin.js(theconfigureServerpost-hook that registers the catch-all SSR middleware).Bug 1 — Overly broad early-return condition
The intent of
"dispatchFetch" in serverEnvwas to opt out for the newFetchableDevEnvironment, which talks to the server viadispatchFetchinstead ofrunner.import. But in Vite 8,RunnableDevEnvironmentalso has adispatchFetchmethod — it inherits it from theDevEnvironmentbase to satisfy theEnvironmentinterface contract. TheRunnableDevEnvironmentclass declaration invite/dist/node/index.d.ts:So
"dispatchFetch" in serverEnvistruefor every dev environment, and the post-hook unconditionally returns. The middleware is never registered.Verified by adding a
configureServerdebug hook in the user'svite.config.ts:(The
"dispatchFetch" in serverEnvcheck was matching the property on the class at plugin load time, not the instance the dev server actually created.)Bug 2 —
instanceofacross ESM module boundariesisRunnableDevEnvironment(env)is implemented asenv instanceof RunnableDevEnvironment, whereRunnableDevEnvironmentis imported by the plugin from"vite". In a monorepo (or any setup where vite is aliased / re-exported under multiple module specifiers), Node's ESM module cache can end up with two distinctRunnableDevEnvironmentclass identities:new RunnableDevEnvironment(...)invite-plus-core/dist/vite/node/chunks/node.js).@tanstack/start-plugin-coreimported at module load time, bound inside the closure ofisRunnableDevEnvironment.The two classes share the same source but live in different module-graph entries.
env instanceof RunnableDevEnvironmentis thereforefalseeven thoughenv.constructor.name === "RunnableDevEnvironment".This is the failure that becomes visible only after the user sets
installDevServerMiddleware: trueto skip Bug 1's early-return:Verified the diagnosis end-to-end by registering a custom
configureServerpost-hook that does the sameserverRunner.import("virtual:tanstack-start-server-entry")call as the plugin would, with noinstanceofcheck — the page renders correctly (200 OK, body contains the expected SSR markup). This isolates the failure to the plugin's gating logic, not to the underlying import / SSR pipeline.Suggested fix
In
@tanstack/start-plugin-core/dist/esm/vite/dev-server-plugin/plugin.js, replace the two identity-based guards with a feature check (the middleware only ever usesserverEnv.runner.import, so check exactly that). This avoids both theinstanceofcross-module problem and the dispatchFetch false positive:The
import { isRunnableDevEnvironment } from "vite"line can be removed (it's only used in the two checks above). Same for theimport { ... } from "vite"if it was only there for that symbol.Why this is the right shape
serverEnv.runner.import(ENTRY_POINTS.server). The only thing we need to know is "can I call.importon it?". That's a property check, not an identity check.dispatchFetchat all, so theRunnableDevEnvironment-now-inherits-dispatchFetchbehavior in Vite 8 (Bug 1) is irrelevant.runner.import, this code keeps working.Suggested follow-up
Once the check is fixed, the
installDevServerMiddlewareoption loses most of its value (it was only ever a workaround for this exact bug). Two options:I'd vote for (1) + (2) combined: keep the option but redefine its semantics as "skip the check", deprecate the old behavior, document in the changelog.
Workaround (until fixed)
Pin Vite to v7. Vite 7's
RunnableDevEnvironmentdoes not exposedispatchFetch(the method was added in Vite 8), so Bug 1's check is false and the middleware is installed normally. Bug 2 may still bite in monorepos on Vite 7 — switching to a single Vite resolution (one Vite in the lockfile, no alias) is enough to dodge it.For projects that must stay on Vite 8 / Vite+, the only currently-available workaround is patching
node_modules/@tanstack/start-plugin-core/dist/esm/vite/dev-server-plugin/plugin.js. I would not recommend that for production.Related
pnpmoverride setup:vite: npm:@voidzero-dev/vite-plus-core@latest+overrides: { vite: "catalog:" }to ensure a single Vite resolution across the workspace. This is the recommended configuration for Vite+ monorepos and is what surfaces the cross-moduleinstanceofissue.@tanstack/router-corev1.171.x ships atanstack-router/skills/router-core/SKILL.mdfamily. A skill for "TanStack Start dev server plugin" covering the environment-type contract would be a good add.Checklist
file:linereferencesComplete minimal reproducer
null
Steps to Reproduce the Bug
null
Expected behavior
null
Screenshots or Videos
No response
Platform
Additional context
No response