Summary
handleServerAction returns unhandled 500s (instead of 4xx responses) for two request shapes on the /_serverFn/<id> endpoint, reproducible on a fresh app with the latest packages (@tanstack/react-start 1.168.49, @tanstack/react-router 1.170.32, vite 7). Bots hit this daily in production on our site: one server-function endpoint answered 7,230×200, 6,263×403 (CSRF, working as intended) and 6×500 in a 24-hour window — the 500s are crawlers replaying captured /_serverFn/…?payload=… URLs with browser headers minus the one custom header.
Reproduction
A minimal app (5 source files, no extra deps):
// src/serverFn.ts
import { createServerFn } from "@tanstack/react-start";
export const getPost = createServerFn({ method: "GET" }).handler(
async () => ({ title: "hello from the server function" }),
);
// src/routes/index.tsx
import { createFileRoute } from "@tanstack/react-router";
import { getPost } from "../serverFn";
export const Route = createFileRoute("/")({
loader: async () => ({ post: await getPost() }),
component: () => <h1>repro</h1>,
});
plus src/routes/__root.tsx (createRootRoute with <Outlet />), src/router.tsx exporting createRouter()/getRouter() over routeTree.gen, and a vite config of tanstackStart() + react(). After vite build, serve dist/server/server.js through any fetch(Request) host (we used a 20-line node:http adapter).
Shape 1 — request without x-tsr-serverFn (the main one)
curl -H 'Sec-Fetch-Site: same-origin' \
'http://localhost:3000/_serverFn/<id>?payload=<valid seroval envelope>' # → 500
curl -H 'x-tsr-serverFn: true' -H 'Sec-Fetch-Site: same-origin' \
'http://localhost:3000/_serverFn/<id>?payload=<same envelope>' # → 200
The envelope is exactly what the client proxy sends: JSON.stringify(seroval.toJSON({data: {...}})), URL-encoded as ?payload=…. The same valid payload returns 200 with the header and 500 without it. A no-payload request without the header also 500s.
Server-side stderr shows only the masked form — the cause repeats the message, so nothing names the defect:
Error: Internal Server Error
at throwRouteHandlerError (.../dist/server/server.js:1385:9)
... 5 lines matching cause stack trace ...
cause: Error: Internal Server Error
unhandled: true
Cause: in the built handleServerAction:
const isServerFn = request.headers.get("x-tsr-serverFn") === "true";
...
const unwrapped = res.result || res.error;
if (!isServerFn) return unwrapped; // ← returns a raw JS object
When the header is absent, the action's return value (any plain object) is returned from the route handler to the host instead of a Response, which the route wrapper converts into the masked 500 above. Any request that passes the CSRF check but does not speak the RPC protocol therefore 500s, regardless of payload validity — including requests that would otherwise succeed (we measured handler-scale work: ~300 ms posts fully rendered before the response path discards them).
Shape 2 — non-JSON GET payload
curl -H 'x-tsr-serverFn: true' -H 'Sec-Fetch-Site: same-origin' \
'http://localhost:3000/_serverFn/<id>?payload=%7Bgarbage%7D' # → 500
const payload2 = payloadParam ? parsePayload(JSON.parse(payloadParam)) : {};
The bare JSON.parse throws SyntaxError before the handler runs; it is logged via the Server Fn Error! catch but still surfaces to the client as an unhandled 500.
Control (working as intended)
A request with no same-origin evidence at all is correctly rejected by the CSRF middleware with 403.
Expected behavior
Both shapes are malformed/protocol-less requests and should answer 400 (or serialize the result consistently for the no-header case). Today they produce unhandled 500s with masked causes, which read as server faults in monitoring and cost a full handler execution before dying.
Suggested fixes
if (!isServerFn): serialize/serializeResult(res) instead of return unwrapped, or answer 400 when the request carries a payload/expects RPC semantics without the protocol header.
- Wrap the GET
JSON.parse(payloadParam) in a try/catch that answers 400 with the parse error message.
Environment
@tanstack/react-start 1.168.49, @tanstack/react-router 1.170.32, vite 7, node 24
- Also reproduced on our production build (mixed 1.166–1.171 resolution), so it is not new in 1.168
Related: #3429 (server functions should not assume JSON payloads).
Summary
handleServerActionreturns unhandled 500s (instead of 4xx responses) for two request shapes on the/_serverFn/<id>endpoint, reproducible on a fresh app with the latest packages (@tanstack/react-start1.168.49,@tanstack/react-router1.170.32, vite 7). Bots hit this daily in production on our site: one server-function endpoint answered 7,230×200, 6,263×403 (CSRF, working as intended) and 6×500 in a 24-hour window — the 500s are crawlers replaying captured/_serverFn/…?payload=…URLs with browser headers minus the one custom header.Reproduction
A minimal app (5 source files, no extra deps):
plus
src/routes/__root.tsx(createRootRoutewith<Outlet />),src/router.tsxexportingcreateRouter()/getRouter()overrouteTree.gen, and a vite config oftanstackStart() + react(). Aftervite build, servedist/server/server.jsthrough anyfetch(Request)host (we used a 20-linenode:httpadapter).Shape 1 — request without
x-tsr-serverFn(the main one)The envelope is exactly what the client proxy sends:
JSON.stringify(seroval.toJSON({data: {...}})), URL-encoded as?payload=…. The same valid payload returns 200 with the header and 500 without it. A no-payload request without the header also 500s.Server-side stderr shows only the masked form — the cause repeats the message, so nothing names the defect:
Cause: in the built
handleServerAction:When the header is absent, the action's return value (any plain object) is returned from the route handler to the host instead of a
Response, which the route wrapper converts into the masked 500 above. Any request that passes the CSRF check but does not speak the RPC protocol therefore 500s, regardless of payload validity — including requests that would otherwise succeed (we measured handler-scale work: ~300 ms posts fully rendered before the response path discards them).Shape 2 — non-JSON GET
payloadThe bare
JSON.parsethrowsSyntaxErrorbefore the handler runs; it is logged via theServer Fn Error!catch but still surfaces to the client as an unhandled 500.Control (working as intended)
A request with no same-origin evidence at all is correctly rejected by the CSRF middleware with 403.
Expected behavior
Both shapes are malformed/protocol-less requests and should answer 400 (or serialize the result consistently for the no-header case). Today they produce unhandled 500s with masked causes, which read as server faults in monitoring and cost a full handler execution before dying.
Suggested fixes
if (!isServerFn): serialize/serializeResult(res)instead ofreturn unwrapped, or answer 400 when the request carries apayload/expects RPC semantics without the protocol header.JSON.parse(payloadParam)in a try/catch that answers 400 with the parse error message.Environment
@tanstack/react-start1.168.49,@tanstack/react-router1.170.32, vite 7, node 24Related: #3429 (server functions should not assume JSON payloads).