Skip to content

fix(start-server-core): return a Response to non-RPC server function callers - #8220

Open
theRizwan wants to merge 1 commit into
TanStack:mainfrom
theRizwan:fix/server-fn-form-action-response
Open

fix(start-server-core): return a Response to non-RPC server function callers#8220
theRizwan wants to merge 1 commit into
TanStack:mainfrom
theRizwan:fix/server-fn-form-action-response

Conversation

@theRizwan

@theRizwan theRizwan commented Sep 2, 2026

Copy link
Copy Markdown

🎯 Changes

Fixes #7745.

Posting a native HTML form to serverFn.url failed with an unhandled 500:

Error: It looks like you forgot to return a response from your server route handler.
    at throwRouteHandlerError (.../createStartHandler.ts)

A native form submission is not an RPC call, so it does not send the x-tsr-serverFn header that the RPC fetcher sends. In handleServerAction that branch returned the unwrapped handler value straight out:

const unwrapped = res.result || res.error
if (!isServerFn) {
  return unwrapped
}

For a handler that returns a plain object, returns nothing, or produces an error, unwrapped is a raw JS value rather than a Response. It flows back through executeMiddleware, where handleCtxResult only recognises Response, redirect and SsrResponse, so ctx.response is never set and getFinalResponse throws ERR_NO_RESPONSE. Only handlers that already returned a Response worked, which matches the reporter's workaround.

This path is a regression from the middleware refactor in #5517, which introduced the shortcut. Before it, every caller went through the serialization path and always produced a Response.

The fix

Restrict the shortcut to values that are already a Response, and let everything else fall through to the existing serializeResult:

if (!isServerFn) {
  if (unwrapped instanceof Response) {
    return unwrapped
  }
} else if (unwrapped instanceof Response) {
  // unchanged RPC handling
}

return serializeResult(res)

Redirects are covered by the same check, because redirect() returns a Response subclass and isRedirect is obj instanceof Response && !!obj.options. So a form action that returns redirect() still passes through byte for byte, and a handler-provided Response still reaches the browser without the internal x-tss-raw marker that only RPC callers need.

Non-RPC callers now get the same application/json body the RPC path produces, which is the pre-#5517 behaviour. If you would rather non-RPC callers receive plain Response.json(value) instead of the seroval encoding, or a non-redirect error mapped to a 500 rather than a 200, I am happy to change it. I kept the restoration minimal because those are format decisions rather than part of the bug.

Tests

New packages/start-server-core/tests/server-functions-handler.test.ts drives handleServerAction through requestHandler and runWithStartContext and asserts on the value it returns, since the invariant being broken is that it must always return a Response.

Three tests fail on main and pass with this change:

handler returns before after
{ ok: true } the raw object 200 application/json
nothing undefined 200
an error the raw Error a Response

Four more are regression guards that pass both before and after: a handler-provided Response passes through with its status, content type and no x-tss-raw marker, a redirect() keeps its 302 and Location, and both RPC paths keep serializing and marking exactly as they did.

Docs

Added a short note to the Progressive Enhancement section explaining what a native form submission receives, and that a redirect() is usually what you want so the browser does not stay on the server function URL.

✅ Checklist

  • I have followed the steps in the Contributing guide.
  • I have tested code changes locally with the relevant test commands, or tests do not apply to this pull request.
  • I fully understand the code in this pull request, including any code generated with AI assistance.

Local verification:

  • pnpm nx run @tanstack/start-server-core:test:unit passes, 8 files and 125 tests
  • pnpm nx run @tanstack/start-server-core:test:types passes
  • pnpm nx run @tanstack/start-server-core:test:eslint passes
  • node scripts/verify-links.ts passes for the docs change

🚀 Release Impact

  • This change affects published code, and I have generated a changeset.
  • This change is docs/CI/dev-only (no release).

Summary by CodeRabbit

  • Bug Fixes

    • Native HTML form submissions now receive serializable server-function results as JSON responses.
    • Handler-provided responses and redirects are preserved, allowing applications to control the browser’s next action.
    • RPC response handling remains unchanged.
  • Documentation

    • Clarified how native form submissions differ from RPC calls and how to return responses or redirects.

…callers

A server function invoked through `serverFn.url`, for example as the action of
a native HTML form, does not send the `x-tsr-serverFn` header that the RPC
fetcher sends. That branch returned `res.result || res.error` directly, so a
handler that returned a plain object, returned nothing, or produced an error
handed a raw JS value back to the HTTP layer. `getFinalResponse` then found no
response on the context and threw ERR_NO_RESPONSE, surfacing as an unhandled
500 with "It looks like you forgot to return a response from your server route
handler". Only handlers that returned a `Response` worked.

Every caller went through the serialization path before the middleware
refactor in TanStack#5517, which added this shortcut. Restrict the shortcut to values
that are already a `Response`, which includes redirects since `redirect()`
returns a `Response` subclass, and let everything else fall through to
`serializeResult`. RPC callers are untouched, and a handler-provided `Response`
still reaches a browser without the internal `x-tss-raw` marker.
@coderabbitai

coderabbitai Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Team

Run ID: 08aeec35-a591-4576-a4f8-2f9028509b2e

📥 Commits

Reviewing files that changed from the base of the PR and between 37877da and c7e109c.

📒 Files selected for processing (4)
  • .changeset/server-fn-form-action-response.md
  • docs/start/framework/react/guide/server-functions.md
  • packages/start-server-core/src/server-functions-handler.ts
  • packages/start-server-core/tests/server-functions-handler.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 8 remain after this review.


📝 Walkthrough

Walkthrough

Changes

Server function response handling

Layer / File(s) Summary
Non-RPC response handling
packages/start-server-core/src/server-functions-handler.ts, .changeset/server-fn-form-action-response.md, docs/start/framework/react/guide/server-functions.md
Non-RPC callers now receive handler-provided Response instances unchanged. Other serializable results are serialized into a response. The changeset and guide document this behavior.
Response behavior validation
packages/start-server-core/tests/server-functions-handler.test.ts
Tests cover native form submissions, redirects, empty and throwing handlers, serialized results, and existing RPC response markers.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Merge Risk: ⚪ Minimal · up to c7e10

Non-RPC server-function callers now consistently receive an HTTP Response while existing redirects, handler-provided responses, and RPC behavior remain unchanged. No actionable merge-blocking risk remains beyond normal checks and review.

Suggested reviewers: schiller-manuel, sheraff

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main fix: returning a Response to non-RPC server function callers.
Description check ✅ Passed The description explains the bug, root cause, fix, tests, documentation update, verification commands, checklist, and release impact. It satisfies the repository template.
Linked Issues check ✅ Passed The changes directly address issue #7745 by preventing native form submissions through serverFn.url from failing with an unhandled 500. Non-Response results now use serialization, while handler-provid…
Out of Scope Changes check ✅ Passed The code change, regression tests, documentation update, and changeset are all related to the linked issue and stated objectives. No unrelated changes are identified.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 1 functions across 2 files. (2 skipped: 2 …
Full details: Linked Issues check

Explanation

The changes directly address issue #7745 by preventing native form submissions through serverFn.url from failing with an unhandled 500. Non-Response results now use serialization, while handler-provided Responses and redirects remain supported.

Full details: Docstring Coverage

Explanation

Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 1 functions across 2 files. (2 skipped: 2 unsupported.)

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

serverFn.url used as a form action throws "forgot to return a response from your server route handler"

1 participant