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
5 changes: 5 additions & 0 deletions .changeset/fix-head-request-server-route-fallback.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/start-server-core': patch
---

fix(start-server-core): fall back HEAD requests to GET then ANY per RFC 9110 §9.3.2
63 changes: 63 additions & 0 deletions e2e/react-start/server-routes/src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ import { Route as MethodsIndexRouteImport } from './routes/methods/index'
import { Route as MethodsOnlyAnyRouteImport } from './routes/methods/only-any'
import { Route as ApiOnlyAnyRouteImport } from './routes/api/only-any'
import { Route as ApiMiddlewareContextRouteImport } from './routes/api/middleware-context'
import { Route as ApiHeadFallbackRouteImport } from './routes/api/head-fallback'
import { Route as ApiGetAndAnyRouteImport } from './routes/api/get-and-any'
import { Route as ApiHeadRedirectFallbackRouteImport } from './routes/api/head-redirect-fallback'
import { Route as ApiParamsFooRouteRouteImport } from './routes/api/params/$foo/route'
import { Route as ApiParamsFooBarRouteImport } from './routes/api/params/$foo/$bar'

Expand Down Expand Up @@ -49,6 +52,21 @@ const ApiOnlyAnyRoute = ApiOnlyAnyRouteImport.update({
path: '/api/only-any',
getParentRoute: () => rootRouteImport,
} as any)
const ApiHeadFallbackRoute = ApiHeadFallbackRouteImport.update({
id: '/api/head-fallback',
path: '/api/head-fallback',
getParentRoute: () => rootRouteImport,
} as any)
const ApiGetAndAnyRoute = ApiGetAndAnyRouteImport.update({
id: '/api/get-and-any',
path: '/api/get-and-any',
getParentRoute: () => rootRouteImport,
} as any)
const ApiHeadRedirectFallbackRoute = ApiHeadRedirectFallbackRouteImport.update({
id: '/api/head-redirect-fallback',
path: '/api/head-redirect-fallback',
getParentRoute: () => rootRouteImport,
} as any)
const ApiMiddlewareContextRoute = ApiMiddlewareContextRouteImport.update({
id: '/api/middleware-context',
path: '/api/middleware-context',
Expand All @@ -71,6 +89,9 @@ export interface FileRoutesByFullPath {
'/merge-middleware-context': typeof MergeMiddlewareContextRoute
'/api/middleware-context': typeof ApiMiddlewareContextRoute
'/api/only-any': typeof ApiOnlyAnyRoute
'/api/head-fallback': typeof ApiHeadFallbackRoute
'/api/get-and-any': typeof ApiGetAndAnyRoute
'/api/head-redirect-fallback': typeof ApiHeadRedirectFallbackRoute
'/methods/only-any': typeof MethodsOnlyAnyRoute
'/methods/': typeof MethodsIndexRoute
'/api/params/$foo': typeof ApiParamsFooRouteRouteWithChildren
Expand All @@ -81,6 +102,9 @@ export interface FileRoutesByTo {
'/merge-middleware-context': typeof MergeMiddlewareContextRoute
'/api/middleware-context': typeof ApiMiddlewareContextRoute
'/api/only-any': typeof ApiOnlyAnyRoute
'/api/head-fallback': typeof ApiHeadFallbackRoute
'/api/get-and-any': typeof ApiGetAndAnyRoute
'/api/head-redirect-fallback': typeof ApiHeadRedirectFallbackRoute
'/methods/only-any': typeof MethodsOnlyAnyRoute
'/methods': typeof MethodsIndexRoute
'/api/params/$foo': typeof ApiParamsFooRouteRouteWithChildren
Expand All @@ -93,6 +117,9 @@ export interface FileRoutesById {
'/merge-middleware-context': typeof MergeMiddlewareContextRoute
'/api/middleware-context': typeof ApiMiddlewareContextRoute
'/api/only-any': typeof ApiOnlyAnyRoute
'/api/head-fallback': typeof ApiHeadFallbackRoute
'/api/get-and-any': typeof ApiGetAndAnyRoute
'/api/head-redirect-fallback': typeof ApiHeadRedirectFallbackRoute
'/methods/only-any': typeof MethodsOnlyAnyRoute
'/methods/': typeof MethodsIndexRoute
'/api/params/$foo': typeof ApiParamsFooRouteRouteWithChildren
Expand All @@ -106,6 +133,9 @@ export interface FileRouteTypes {
| '/merge-middleware-context'
| '/api/middleware-context'
| '/api/only-any'
| '/api/head-fallback'
| '/api/get-and-any'
| '/api/head-redirect-fallback'
| '/methods/only-any'
| '/methods/'
| '/api/params/$foo'
Expand All @@ -116,6 +146,9 @@ export interface FileRouteTypes {
| '/merge-middleware-context'
| '/api/middleware-context'
| '/api/only-any'
| '/api/head-fallback'
| '/api/get-and-any'
| '/api/head-redirect-fallback'
| '/methods/only-any'
| '/methods'
| '/api/params/$foo'
Expand All @@ -127,6 +160,9 @@ export interface FileRouteTypes {
| '/merge-middleware-context'
| '/api/middleware-context'
| '/api/only-any'
| '/api/head-fallback'
| '/api/get-and-any'
| '/api/head-redirect-fallback'
| '/methods/only-any'
| '/methods/'
| '/api/params/$foo'
Expand All @@ -139,6 +175,9 @@ export interface RootRouteChildren {
MergeMiddlewareContextRoute: typeof MergeMiddlewareContextRoute
ApiMiddlewareContextRoute: typeof ApiMiddlewareContextRoute
ApiOnlyAnyRoute: typeof ApiOnlyAnyRoute
ApiHeadFallbackRoute: typeof ApiHeadFallbackRoute
ApiGetAndAnyRoute: typeof ApiGetAndAnyRoute
ApiHeadRedirectFallbackRoute: typeof ApiHeadRedirectFallbackRoute
ApiParamsFooRouteRoute: typeof ApiParamsFooRouteRouteWithChildren
}

Expand Down Expand Up @@ -186,6 +225,27 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiOnlyAnyRouteImport
parentRoute: typeof rootRouteImport
}
'/api/head-fallback': {
id: '/api/head-fallback'
path: '/api/head-fallback'
fullPath: '/api/head-fallback'
preLoaderRoute: typeof ApiHeadFallbackRouteImport
parentRoute: typeof rootRouteImport
}
'/api/get-and-any': {
id: '/api/get-and-any'
path: '/api/get-and-any'
fullPath: '/api/get-and-any'
preLoaderRoute: typeof ApiGetAndAnyRouteImport
parentRoute: typeof rootRouteImport
}
'/api/head-redirect-fallback': {
id: '/api/head-redirect-fallback'
path: '/api/head-redirect-fallback'
fullPath: '/api/head-redirect-fallback'
preLoaderRoute: typeof ApiHeadRedirectFallbackRouteImport
parentRoute: typeof rootRouteImport
}
'/api/middleware-context': {
id: '/api/middleware-context'
path: '/api/middleware-context'
Expand Down Expand Up @@ -241,6 +301,9 @@ const rootRouteChildren: RootRouteChildren = {
MergeMiddlewareContextRoute: MergeMiddlewareContextRoute,
ApiMiddlewareContextRoute: ApiMiddlewareContextRoute,
ApiOnlyAnyRoute: ApiOnlyAnyRoute,
ApiHeadFallbackRoute: ApiHeadFallbackRoute,
ApiGetAndAnyRoute: ApiGetAndAnyRoute,
ApiHeadRedirectFallbackRoute: ApiHeadRedirectFallbackRoute,
ApiParamsFooRouteRoute: ApiParamsFooRouteRouteWithChildren,
}
export const routeTree = rootRouteImport
Expand Down
16 changes: 16 additions & 0 deletions e2e/react-start/server-routes/src/routes/api/get-and-any.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/api/get-and-any')({
server: {
handlers: {
GET: () =>
new Response(null, {
headers: { 'x-handler': 'GET' },
}),
ANY: ({ request }) =>
new Response(null, {
headers: { 'x-handler': 'ANY', 'x-method': request.method },
}),
},
},
})
14 changes: 14 additions & 0 deletions e2e/react-start/server-routes/src/routes/api/head-fallback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { createFileRoute } from '@tanstack/react-router'

// A server-only route with only a GET handler.
// Used to test that HEAD requests fall back to the GET handler per RFC 9110.
export const Route = createFileRoute('/api/head-fallback')({
server: {
handlers: {
GET: () =>
new Response('body', {
headers: { 'content-type': 'application/xml; charset=utf-8' },
}),
},
},
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { createFileRoute, redirect } from '@tanstack/react-router'

export const Route = createFileRoute('/api/head-redirect-fallback')({
server: {
handlers: {
GET: () => redirect({ to: '/api/head-fallback', statusCode: 307 }),
},
},
})
73 changes: 73 additions & 0 deletions e2e/react-start/server-routes/tests/server-routes.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,79 @@ test('merge-middleware-context', async ({ page }) => {
expect(contextResult).toContain('test')
})

test.describe('HEAD fallback', () => {
test('strips body and preserves headers when falling back to GET', async ({
page,
}) => {
await page.goto('/')
const result = await page.evaluate(async () => {
const res = await fetch('/api/head-fallback', { method: 'HEAD' })
return {
status: res.status,
contentType: res.headers.get('content-type'),
body: await res.text(),
}
})
expect(result.status).toBe(200)
expect(result.contentType).toBe('application/xml; charset=utf-8')
expect(result.body).toBe('')
})

test('strips body and preserves headers when falling back to ANY', async ({
page,
}) => {
await page.goto('/')
const result = await page.evaluate(async () => {
const res = await fetch('/api/only-any', { method: 'HEAD' })
return {
status: res.status,
xHandler: res.headers.get('x-handler'),
xMethod: res.headers.get('x-method'),
body: await res.text(),
}
})
expect(result.status).toBe(200)
expect(result.xHandler).toBe('ANY')
expect(result.xMethod).toBe('HEAD')
expect(result.body).toBe('')
})

test('prefers GET over ANY for HEAD requests', async ({ page }) => {
await page.goto('/')
const result = await page.evaluate(async () => {
const res = await fetch('/api/get-and-any', { method: 'HEAD' })
return {
status: res.status,
xHandler: res.headers.get('x-handler'),
body: await res.text(),
}
})
expect(result.status).toBe(200)
expect(result.xHandler).toBe('GET')
expect(result.body).toBe('')
})

test('preserves Location header when GET handler returns a redirect', async ({
page,
}) => {
await page.goto('/')
// HEAD /api/head-redirect-fallback → server returns 307 Location:/api/head-fallback
// Browser follows the redirect: HEAD /api/head-fallback → 200 with correct headers
// If the Location header were lost, the browser could not follow and would see a 307.
const result = await page.evaluate(async () => {
const res = await fetch('/api/head-redirect-fallback', { method: 'HEAD' })
return {
status: res.status,
contentType: res.headers.get('content-type'),
body: await res.text(),
}
})
expect(result.status).toBe(200)
expect(result.contentType).toBe('application/xml; charset=utf-8')
expect(result.body).toBe('')
})
})

test.describe('methods', () => {
test('only ANY', async ({ page }) => {
await page.goto('/methods/only-any')
Expand Down
21 changes: 20 additions & 1 deletion packages/start-server-core/src/createStartHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -891,14 +891,22 @@ async function handleServerRoutes({

// Add handler middleware if exact match
const server = foundRoute?.options.server
let isHeadFallback = false
if (server?.handlers && isExactMatch) {
const handlers =
typeof server.handlers === 'function'
? server.handlers({ createHandlers: (d: any) => d })
: server.handlers

const requestMethod = request.method.toUpperCase() as RouteMethod
const handler = handlers[requestMethod] ?? handlers['ANY']
// Per RFC 9110 §9.3.2, HEAD must return the same header fields as GET.
// Priority for HEAD: explicit HEAD handler → GET → ANY (last resort).
const handler =
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.

if both GET and ANY exist, HEAD uses ANY, not GET.

could be solved via:

const handler =
  requestMethod === 'HEAD'
    ? handlers['HEAD'] ?? handlers['GET'] ?? handlers['ANY']
    : handlers[requestMethod] ?? handlers['ANY']

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.

add a separate E2E test route for this

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed. Handler selection is now handlers['HEAD'] ?? handlers['GET'] ?? handlers['ANY'], so GET always wins over ANY for HEAD requests.

Added /api/get-and-any with separate GET and ANY handlers, and a test that sends HEAD /api/get-and-any and asserts x-handler: GET.

requestMethod === 'HEAD'
? (handlers['HEAD'] ?? handlers['GET'] ?? handlers['ANY'])
: (handlers[requestMethod] ?? handlers['ANY'])
isHeadFallback =
requestMethod === 'HEAD' && handler !== undefined && !handlers['HEAD']

if (handler) {
const mayDefer = !!foundRoute.options.component
Expand Down Expand Up @@ -931,5 +939,16 @@ async function handleServerRoutes({
pathname,
})

// RFC 9110 §9.3.2: HEAD must carry the same header fields as GET but no body.
// Resolve any redirect before stripping so the Location header survives.
if (isHeadFallback) {
const resolved = await handleRedirectResponse(
ctx.response as Response,
request,
getRouter,
)
return new Response(null, resolved)
Comment on lines +944 to +950
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot May 3, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't synthesize a 200 OK when no response was produced.

Line 946 forces ctx.response to Response, but if a fallback GET/ANY chain exits without returning or calling next, handleRedirectResponse passes undefined through and Line 950 turns that into a fresh 200 OK. That hides the original handler bug and makes a broken HEAD route look successful.

Suggested fix
   if (isHeadFallback) {
+    if (!ctx.response) {
+      throwRouteHandlerError()
+    }
+
     const resolved = await handleRedirectResponse(
-      ctx.response as Response,
+      ctx.response,
       request,
       getRouter,
     )
     return new Response(null, resolved)
   }

As per coding guidelines, **/*.{ts,tsx}: Use TypeScript strict mode with extensive type safety.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/start-server-core/src/createStartHandler.ts` around lines 944 - 950,
The current isHeadFallback branch forces ctx.response through
handleRedirectResponse and always wraps its result into new Response(...), which
converts an undefined/absent response into a synthetic 200 OK; change the logic
in the isHeadFallback block (the call to handleRedirectResponse with
ctx.response, request, getRouter) to check the returned value (from
handleRedirectResponse) and if it is undefined/null do not create a new
Response—propagate undefined (or return early) instead; update types so the
variable (e.g., resolved) is typed as ResponseInit | undefined and the code only
calls new Response when resolved is a real ResponseInit object.

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.

this makes sense. please incorporate

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.

@schiller-manuel, here is the fix incorporated:

  • Guard against ctx.response being undefined/falsy before passing it to handleRedirectResponse
  • Remove the unsafe as Response cast

Why this matters:
Without the guard, if a fallback GET/ANY handler exits without returning a Response (or calling next), ctx.response is undefined. handleRedirectResponse passes undefined straight through, and new Response(null, undefined) quietly synthesizes a 200 OK — masking the bug entirely. Calling throwRouteHandlerError() instead surfaces the developer mistake with the same clear message used everywhere else in the file.


Failed to handle agent chat message. Please try again.

}

return ctx.response
}
Loading