Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/start-response-state.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@tanstack/react-start': patch
'@tanstack/solid-start': patch
'@tanstack/vue-start': patch
'@tanstack/start-client-core': patch
'@tanstack/start-plugin-core': patch
'@tanstack/start-server-core': patch
---

Improve Start response reconciliation, session cookie handling, and server-entry error recovery. Server entries now recover Start error responses with `handleStartError`, response helpers reconcile across server routes, SSR, server functions, redirects, and error responses, and serialized server-function transport headers are protected from helper overrides.
149 changes: 143 additions & 6 deletions docs/start/framework/react/guide/server-entry-point.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,60 @@ TanStack Start exposes a wrapper to make creation type-safe. This is done in the
import handler, { createServerEntry } from '@tanstack/react-start/server-entry'

export default createServerEntry({
fetch(request) {
return handler.fetch(request)
fetch(request, opts) {
return handler.fetch(request, opts)
},
})
```

Whether we are statically generating our app or serving it dynamically, the `server.ts` file is the entry point for doing all SSR-related work as well as for handling server routes and server function requests.

## Configuring a Custom Server Entry

By default, Start uses the generated server entry. If you provide your own server entry at a non-standard path, configure it in your bundler plugin. The `server.entry` path is resolved relative to `srcDirectory`, which defaults to `src`.

<!-- ::start:tabs variant="bundler" -->

# Vite

```ts title="vite.config.ts"
import { defineConfig } from 'vite'
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
import viteReact from '@vitejs/plugin-react'

export default defineConfig({
plugins: [
tanstackStart({
server: {
entry: './server-entry.ts',
},
}),
viteReact(),
],
})
```

# Rsbuild

```ts title="rsbuild.config.ts"
import { defineConfig } from '@rsbuild/core'
import { pluginReact } from '@rsbuild/plugin-react'
import { tanstackStart } from '@tanstack/react-start/plugin/rsbuild'

export default defineConfig({
plugins: [
pluginReact(),
tanstackStart({
server: {
entry: './server-entry.ts',
},
}),
],
})
```

<!-- ::end:tabs -->

## Custom Server Handlers

You can create custom server handlers to modify how your application is rendered:
Expand All @@ -53,13 +99,101 @@ const customHandler = defineHandlerCallback((ctx) => {
return defaultStreamHandler(ctx)
})

const fetch = createStartHandler(customHandler)
const startHandler = createStartHandler(customHandler)

export default createServerEntry({
fetch,
fetch(request, opts) {
return startHandler(request, opts)
},
})
```

## Error Handling

The generated server entry catches uncaught errors and calls `handleStartError(error)`. Response helpers such as `setResponseStatus`, `setResponseHeader`, and `setCookie` are reconciled onto the error response.

This default behavior means Start owns the uncaught-error boundary. If a loader, server route, or middleware throws after setting response state, Start still returns a valid `Response` with the intended status, headers, and cookies. Server function RPC errors are handled before this top-level boundary so the client protocol response stays intact.

For example, this should return `401`, not a generic `500`:

```tsx
import { createMiddleware } from '@tanstack/react-start'
import {
setResponseHeader,
setResponseStatus,
} from '@tanstack/react-start/server'

const authMiddleware = createMiddleware().server(() => {
setResponseStatus(401, 'Unauthorized')
setResponseHeader('www-authenticate', 'Bearer')
throw new Error('Unauthorized')
})
```

This conversion does more than create a plain `new Response(...)`. It preserves response state that Start captured during the request:

- Status and status text from `setResponseStatus`
- Headers from `setResponseHeader` and `setResponseHeaders`
- Cookies from `setCookie`, including multiple `Set-Cookie` headers
- HTTP-style error metadata such as `error.status`, `error.statusText`, `error.headers`, and `error.cause.headers`
- Protocol headers used by server function responses
- Null-body response rules for `HEAD`, `204`, `205`, and `304`

`createStartHandler(...)` rethrows uncaught errors after associating them with the current Start request. If you write a custom server entry and need custom top-level error handling, catch around the returned fetch handler and call `handleStartError(error)` yourself:

```tsx
// src/server.ts
import {
createStartHandler,
defaultStreamHandler,
handleStartError,
} from '@tanstack/react-start/server'
import { createServerEntry } from '@tanstack/react-start/server-entry'

const startHandler = createStartHandler(defaultStreamHandler)

export default createServerEntry({
async fetch(request, opts) {
try {
return await startHandler(request, opts)
} catch (error) {
// Add logging, reporting, or custom branching here.
return handleStartError(error)
}
},
})
```

Use `handleStartError(error)` when you want custom top-level logic, such as logging or reporting, but still want Start to produce the same error response as the generated server entry.

`handleStartError(error)` is useful because the error is caught outside the active Start handler. It recovers the Start request state associated with non-primitive thrown errors and reconciles that state onto the error response. Primitive throws cannot carry that association.

If you do not want to preserve response helpers that ran before the error, do not call `handleStartError`. Return your own response instead:

```tsx
// src/server.ts
import {
createStartHandler,
defaultStreamHandler,
} from '@tanstack/react-start/server'
import { createServerEntry } from '@tanstack/react-start/server-entry'

const startHandler = createStartHandler(defaultStreamHandler)

export default createServerEntry({
async fetch(request, opts) {
try {
return await startHandler(request, opts)
} catch (error) {
console.error(error)
return new Response('Internal Server Error', { status: 500 })
}
},
})
```

Use this pattern when your server entry or deployment platform should fully own the error response and should ignore any status, headers, or cookies set earlier in the Start request.

## Request context

When your server needs to pass additional, typed data into request handlers (for example, authenticated user info, a database connection, or per-request flags), register a request context type via TypeScript module augmentation. The registered context is delivered as the second argument to the server `fetch` handler and is available throughout the server-side middleware chain — including global middleware, request/function middleware, server routes, server functions, and the router itself.
Expand All @@ -83,8 +217,11 @@ declare module '@tanstack/react-router' {
}

export default createServerEntry({
async fetch(request) {
return handler.fetch(request, { context: { hello: 'world', foo: 123 } })
fetch(request, opts) {
return handler.fetch(request, {
...opts,
context: { hello: 'world', foo: 123 },
})
},
})
```
Expand Down
22 changes: 22 additions & 0 deletions e2e/react-start/response-reconciliation/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
node_modules
package-lock.json
yarn.lock

.DS_Store
.cache
.tanstack
.env
.vercel
.output
/build/
/api/
/server/build
/public/build# Sentry Config File
.env.sentry-build-plugin
Comment on lines +14 to +15

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Fix malformed ignore entry on Line 14.

/public/build# Sentry Config File is parsed as one literal pattern, so /public/build may not be ignored as intended.

Suggested patch
-/public/build# Sentry Config File
+# Sentry Config File
+/public/build
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/public/build# Sentry Config File
.env.sentry-build-plugin
# Sentry Config File
/public/build
.env.sentry-build-plugin
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@e2e/react-start/response-reconciliation/.gitignore` around lines 14 - 15, The
.gitignore entry "/public/build# Sentry Config File" is malformed so
"/public/build" won't be ignored; split the line into a pure pattern and a
comment: replace the single malformed line with a standalone pattern
"/public/build" and a separate comment line "# Sentry Config File" (i.e., update
the entry in response-reconciliation/.gitignore that currently reads
"/public/build# Sentry Config File").

/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
/dist/
/dist-*/
/port-*.txt
4 changes: 4 additions & 0 deletions e2e/react-start/response-reconciliation/.prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
**/build
**/public
pnpm-lock.yaml
routeTree.gen.ts
84 changes: 84 additions & 0 deletions e2e/react-start/response-reconciliation/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
{
"name": "tanstack-react-start-e2e-response-reconciliation",
"private": true,
"sideEffects": false,
"type": "module",
"scripts": {
"dev": "pnpm dev:vite --port 3000",
"dev:e2e": "pnpm dev:vite",
"dev:vite": "vite dev",
"dev:rsbuild": "rsbuild dev",
"build": "pnpm build:vite",
"build:vite": "vite build && tsc --noEmit",
"build:rsbuild": "rsbuild build && tsc --noEmit",
"preview": "vite preview",
"start": "node server.js",
"test:e2e:local": "pnpm build && playwright test --project=chromium"
},
"dependencies": {
"@tanstack/react-router": "workspace:*",
"@tanstack/react-start": "workspace:*",
"react": "^19.2.3",
"react-dom": "^19.2.3"
},
"devDependencies": {
"@playwright/test": "^1.57.0",
"@rsbuild/core": "^2.0.8",
"@rsbuild/plugin-react": "^2.0.0",
"@tanstack/router-e2e-utils": "workspace:^",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Use workspace:* for internal dependency on Line 28.

@tanstack/router-e2e-utils is currently workspace:^; this conflicts with the repository rule for internal packages.

As per coding guidelines, "**/package.json: Use workspace protocol (workspace:*) for internal dependencies".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@e2e/react-start/response-reconciliation/package.json` at line 28, Update the
internal dependency declaration for "`@tanstack/router-e2e-utils`" in package.json
to use the workspace protocol exactly as "workspace:*" instead of "workspace:^";
locate the dependency entry for "`@tanstack/router-e2e-utils`" and replace its
version specifier "workspace:^" with "workspace:*", then reinstall/update the
lockfile to ensure consistent dependency resolution.

Source: Coding guidelines

"@types/node": "25.0.9",
"@types/react": "^19.2.8",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"srvx": "^0.11.9",
"typescript": "^6.0.2",
"vite": "^8.0.14"
},
"nx": {
"targets": {
"test:e2e": {
"parallelism": false
},
"test:e2e--vite-ssr": {
"parallelism": false
},
"test:e2e--vite-ssr--custom-entry": {
"parallelism": false
},
"test:e2e--rsbuild-ssr": {
"parallelism": false
},
"test:e2e--rsbuild-ssr--custom-entry": {
"parallelism": false
}
},
"metadata": {
"playwrightModes": [
{
"toolchain": "vite",
"mode": "ssr"
},
{
"toolchain": "vite",
"mode": "ssr",
"name": "custom-entry",
"env": {
"TSS_E2E_SERVER_ENTRY": "server-entry"
}
},
{
"toolchain": "rsbuild",
"mode": "ssr"
},
{
"toolchain": "rsbuild",
"mode": "ssr",
"name": "custom-entry",
"env": {
"TSS_E2E_SERVER_ENTRY": "server-entry"
}
}
]
}
}
}
41 changes: 41 additions & 0 deletions e2e/react-start/response-reconciliation/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { defineConfig, devices } from '@playwright/test'
import { getTestServerPort } from '@tanstack/router-e2e-utils'
import packageJson from './package.json' with { type: 'json' }

const e2ePortKey = process.env.E2E_PORT_KEY ?? packageJson.name
const toolchain = process.env.E2E_TOOLCHAIN ?? 'vite'
const distDir = process.env.E2E_DIST_DIR ?? 'dist'
const serverEntry = process.env.TSS_E2E_SERVER_ENTRY ?? ''
const serverEntryMode = serverEntry ? 'custom' : 'default'

export const PORT = await getTestServerPort(`${e2ePortKey}-${serverEntryMode}`)
const baseURL = `http://localhost:${PORT}`

export default defineConfig({
testDir: './tests',
workers: 1,
reporter: [['line']],
use: {
baseURL,
},
webServer: {
command: `pnpm start`,
url: baseURL,
reuseExistingServer: !process.env.CI,
stdout: 'pipe',
env: {
PORT: String(PORT),
VITE_SERVER_PORT: String(PORT),
E2E_DIST_DIR: distDir,
E2E_TOOLCHAIN: toolchain,
E2E_PORT_KEY: e2ePortKey,
TSS_E2E_SERVER_ENTRY: serverEntry,
},
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
})
20 changes: 20 additions & 0 deletions e2e/react-start/response-reconciliation/rsbuild.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { defineConfig } from '@rsbuild/core'
import { pluginReact } from '@rsbuild/plugin-react'
import { tanstackStart } from '@tanstack/react-start/plugin/rsbuild'

const serverEntry = process.env.TSS_E2E_SERVER_ENTRY
const outDir = process.env.E2E_DIST_DIR ?? 'dist'

export default defineConfig({
plugins: [
pluginReact(),
tanstackStart({
server: serverEntry ? { entry: serverEntry } : undefined,
}),
],
output: {
distPath: {
root: outDir,
},
},
})
Loading
Loading