Which project does this relate to?
Start
Describe the bug
createClientRpc() computes a server function's request URL at module evaluation time (packages/start-client-core/src/client-rpc/createClientRpc.ts):
export function createClientRpc(functionId: string) {
const url = process.env.TSS_SERVER_FN_BASE + functionId
// …
}
TSS_SERVER_FN_BASE is supplied as a Vite define (defineReplaceEnv() in start-plugin-core). During vite dev that define is not applied to files inside node_modules — the dev-served module still contains the literal process.env.TSS_SERVER_FN_BASE, and the value exists only because Vite's /@vite/env shim assigns globalThis.process.env at runtime. The URL is therefore only correct if /@vite/env has executed before the generated server-function module evaluates.
When it hasn't, url becomes the string "undefined" + functionId, which is a relative URL. The fetch then resolves against the current document path — e.g. /posts/1/undefinedsrc_utils_posts_tsx--fetchPost_createServerFn_handler — never matches TSS_SERVER_FN_BASE in createStartHandler, falls through to the router, and since serverFnFetcher sends accept: application/x-ndjson, application/json the router answers:
500 {"error":"Only HTML requests are supported here"}
serverFnFetcher rethrows that as new Error(await response.text()), so app authors see a bare Error whose message is raw JSON, thrown from whichever component happens to call a server function first — with nothing pointing at the real cause.
Two things make it look random rather than reproducible:
- it depends on module evaluation order, which shifts with dep-optimization state, HMR and import graph changes;
- a relative URL still resolves correctly on
/ (undefined… → /undefined… is wrong but at the root there is no prefix to inherit), while on /foo/bar it inherits the path. So the same app "works" on one page and fails on another.
Complete minimal reproducer
https://stackblitz.com/github/tanstack/router/tree/main/examples/react/start-basic
No custom code is needed — the example's own server functions (src/utils/posts.tsx) are enough. Any Start app with a server function shows it.
Steps to Reproduce the Bug
-
Start the dev server for the example above.
-
Confirm the define is not statically applied in dev — request the dev-served dependency module:
curl -s 'http://localhost:3000/@fs/<abs-path>/node_modules/@tanstack/start-client-core/dist/esm/client-rpc/createClientRpc.js' | grep TSS_SERVER_FN_BASE
const url = process.env.TSS_SERVER_FN_BASE + functionId; // unreplaced
In the browser, process.env.TSS_SERVER_FN_BASE is a runtime property (Object.keys(globalThis.process.env) → TSS_CLIENT_OUTPUT_DIR, TSS_DEV_SERVER, TSS_ROUTER_BASEPATH, TSS_SERVER_FN_BASE, TSS_SHELL), i.e. it comes from the shim, not from a build-time replacement.
-
Force the ordering that the shim normally hides. On any non-root route, in the browser console:
delete process.env.TSS_SERVER_FN_BASE
const m = await import('/src/utils/posts.tsx?forcereeval=1') // fresh module instance
await m.fetchPost({ data: '1' })
Error: {"error":"Only HTML requests are supported here"}
Network: GET /posts/undefinedsrc_utils_posts_tsx--fetchPost_createServerFn_handler?payload=…
Step 3 forces deterministically what module ordering otherwise decides by chance; the timing dependence is exactly why this surfaces intermittently in real apps rather than always.
Expected behavior
A server function's request URL shouldn't depend on module evaluation order in dev — it should be absolute in every case.
A few directions (not prescriptive):
- compute
url lazily inside clientFn, so the read happens at call time, long after the dev shims are installed (url is also exposed on the returned object, so a getter would preserve that);
- or fall back to the configured default when the define is missing, instead of string-concatenating
undefined;
- or read
import.meta.env.TSS_SERVER_FN_BASE — defineReplaceEnv() already defines both spellings, and Vite provides import.meta.env per module in dev.
Separately, and regardless of which fix is chosen: a request that reaches the router with a non-HTML Accept currently reports as 500, which sent me looking for a server fault. See #7913 for that side of it.
Screenshots or Videos
No response
Platform
- Router / Start Version:
@tanstack/react-start 1.133.3 — the relevant line is unchanged in @tanstack/start-client-core 1.170.27, and defineReplaceEnv / the dev client-entry injection are unchanged too, so this is not fixed by upgrading
- OS: macOS (darwin 25.6.0)
- Browser: Chrome / Chromium
- Bundler:
rolldown-vite (aliased as vite), Vite 7 dev server
- Also present with
@cloudflare/vite-plugin as the SSR environment, though that is not needed to reproduce
Additional context
Workaround for app authors until this is fixed — seed the value from an inline classic script in <head>, which runs while the head is parsed and therefore before any module script exists:
<script>globalThis.process ??= {}; globalThis.process.env ??= {}; globalThis.process.env.TSS_SERVER_FN_BASE ??= "/_serverFn/";</script>
??= yields to the real value whenever the shim wins the race anyway, and the snippet can be gated to dev so production keeps using the statically replaced define.
Which project does this relate to?
Start
Describe the bug
createClientRpc()computes a server function's request URL at module evaluation time (packages/start-client-core/src/client-rpc/createClientRpc.ts):TSS_SERVER_FN_BASEis supplied as a Vitedefine(defineReplaceEnv()instart-plugin-core). Duringvite devthat define is not applied to files insidenode_modules— the dev-served module still contains the literalprocess.env.TSS_SERVER_FN_BASE, and the value exists only because Vite's/@vite/envshim assignsglobalThis.process.envat runtime. The URL is therefore only correct if/@vite/envhas executed before the generated server-function module evaluates.When it hasn't,
urlbecomes the string"undefined" + functionId, which is a relative URL. The fetch then resolves against the current document path — e.g./posts/1/undefinedsrc_utils_posts_tsx--fetchPost_createServerFn_handler— never matchesTSS_SERVER_FN_BASEincreateStartHandler, falls through to the router, and sinceserverFnFetchersendsaccept: application/x-ndjson, application/jsonthe router answers:serverFnFetcherrethrows that asnew Error(await response.text()), so app authors see a bareErrorwhose message is raw JSON, thrown from whichever component happens to call a server function first — with nothing pointing at the real cause.Two things make it look random rather than reproducible:
/(undefined…→/undefined…is wrong but at the root there is no prefix to inherit), while on/foo/barit inherits the path. So the same app "works" on one page and fails on another.Complete minimal reproducer
https://stackblitz.com/github/tanstack/router/tree/main/examples/react/start-basic
No custom code is needed — the example's own server functions (
src/utils/posts.tsx) are enough. Any Start app with a server function shows it.Steps to Reproduce the Bug
Start the dev server for the example above.
Confirm the define is not statically applied in dev — request the dev-served dependency module:
In the browser,
process.env.TSS_SERVER_FN_BASEis a runtime property (Object.keys(globalThis.process.env)→TSS_CLIENT_OUTPUT_DIR, TSS_DEV_SERVER, TSS_ROUTER_BASEPATH, TSS_SERVER_FN_BASE, TSS_SHELL), i.e. it comes from the shim, not from a build-time replacement.Force the ordering that the shim normally hides. On any non-root route, in the browser console:
Step 3 forces deterministically what module ordering otherwise decides by chance; the timing dependence is exactly why this surfaces intermittently in real apps rather than always.
Expected behavior
A server function's request URL shouldn't depend on module evaluation order in dev — it should be absolute in every case.
A few directions (not prescriptive):
urllazily insideclientFn, so the read happens at call time, long after the dev shims are installed (urlis also exposed on the returned object, so a getter would preserve that);undefined;import.meta.env.TSS_SERVER_FN_BASE—defineReplaceEnv()already defines both spellings, and Vite providesimport.meta.envper module in dev.Separately, and regardless of which fix is chosen: a request that reaches the router with a non-HTML
Acceptcurrently reports as500, which sent me looking for a server fault. See #7913 for that side of it.Screenshots or Videos
No response
Platform
@tanstack/react-start1.133.3 — the relevant line is unchanged in@tanstack/start-client-core1.170.27, anddefineReplaceEnv/ the dev client-entry injection are unchanged too, so this is not fixed by upgradingrolldown-vite(aliased asvite), Vite 7 dev server@cloudflare/vite-pluginas the SSR environment, though that is not needed to reproduceAdditional context
Workaround for app authors until this is fixed — seed the value from an inline classic script in
<head>, which runs while the head is parsed and therefore before any module script exists:??=yields to the real value whenever the shim wins the race anyway, and the snippet can be gated to dev so production keeps using the statically replaced define.