diff --git a/docs/start/framework/react/guide/server-functions.md b/docs/start/framework/react/guide/server-functions.md
index a94c0558180..427a6abcbf6 100644
--- a/docs/start/framework/react/guide/server-functions.md
+++ b/docs/start/framework/react/guide/server-functions.md
@@ -127,6 +127,123 @@ export const submitForm = createServerFn({ method: 'POST' })
})
```
+## Raw Handler for Streaming Uploads
+
+For advanced use cases like streaming file uploads or accessing the raw request body, use `.rawHandler()` instead of `.handler()`. This gives you direct access to the Request object without automatic body parsing.
+
+### Why Use Raw Handler?
+
+The raw handler is essential when you need to:
+
+- **Stream large file uploads** directly to cloud storage without loading them into memory
+- **Enforce size limits during upload** rather than after the entire file is loaded
+- **Access raw request streams** for custom body parsing (e.g., multipart/form-data with busboy)
+- **Implement proper backpressure** for large data transfers
+- **Minimize memory footprint** for file uploads
+
+### Basic Raw Handler
+
+```tsx
+import { createServerFn } from '@tanstack/react-start'
+
+export const uploadFile = createServerFn({ method: 'POST' }).rawHandler(
+ async ({ request, signal }) => {
+ // Access the raw request object
+ const contentType = request.headers.get('content-type')
+ const body = await request.text()
+
+ return new Response(
+ JSON.stringify({
+ contentType,
+ size: body.length,
+ }),
+ )
+ },
+)
+```
+
+### Streaming File Upload Example
+
+With raw handlers, you can stream files directly to cloud storage without buffering them in memory:
+
+```tsx
+import { createServerFn } from '@tanstack/react-start'
+
+export const uploadFile = createServerFn({ method: 'POST' })
+ .middleware([authMiddleware]) // Middleware context is available!
+ .rawHandler(async ({ request, signal, context }) => {
+ // Access middleware context (user, auth, etc.)
+ const userId = context.user.id
+
+ // Access the raw request body stream
+ const body = request.body
+
+ if (!body) {
+ return new Response('No file provided', { status: 400 })
+ }
+
+ // Stream directly to your storage (S3, Azure, etc.)
+ // You can use libraries like busboy to parse multipart/form-data
+ // and enforce size limits DURING upload
+ await streamToStorage(body, {
+ userId,
+ maxSize: 25 * 1024 * 1024, // 25MB limit
+ signal, // Pass for cancellation support
+ })
+
+ return new Response(JSON.stringify({ success: true }), {
+ headers: { 'Content-Type': 'application/json' },
+ })
+ })
+```
+
+### Client-Side Usage
+
+The raw handler works seamlessly from the client with FormData:
+
+```tsx
+import { useMutation } from '@tanstack/react-query'
+import { uploadFile } from './server-functions'
+
+function FileUpload() {
+ const uploadMutation = useMutation({
+ mutationFn: async (file: File) => {
+ const formData = new FormData()
+ formData.append('file', file)
+
+ return uploadFile({ data: formData })
+ },
+ })
+
+ return (
+ {
+ const file = e.target.files?.[0]
+ if (file) uploadMutation.mutate(file)
+ }}
+ />
+ )
+}
+```
+
+### Key Differences from Regular Handler
+
+| Feature | `.handler()` | `.rawHandler()` |
+| ------------ | ----------------------------------- | ------------------------------ |
+| Body Parsing | Automatic (FormData, JSON) | Manual - you control it |
+| Memory Usage | Loads entire body into memory first | Can stream directly |
+| Size Limits | Can't enforce during upload | Enforce during upload |
+| Use Case | Standard data/form handling | Large files, streaming |
+| Parameters | `{ data, context, signal }` | `{ request, context, signal }` |
+
+### Important Notes
+
+1. **No automatic body parsing** - With `rawHandler`, the request body is NOT automatically parsed. You must handle it yourself.
+2. **No data parameter** - Raw handlers receive `{ request, signal, context }` instead of the usual `{ data, context, signal }`. There is no automatic data parsing/validation.
+3. **Middleware context is available** - Request middleware still runs and you have full access to the middleware context (authentication, user info, etc.).
+4. **Response handling** - Always return a `Response` object from raw handlers.
+
## Error Handling & Redirects
Server functions can throw errors, redirects, and not-found responses that are handled automatically when called from route lifecycles or components using `useServerFn()`.
diff --git a/packages/start-client-core/src/createServerFn.ts b/packages/start-client-core/src/createServerFn.ts
index bc95a6b5b9a..d3b9d84a449 100644
--- a/packages/start-client-core/src/createServerFn.ts
+++ b/packages/start-client-core/src/createServerFn.ts
@@ -88,6 +88,80 @@ export const createServerFn: CreateServerFn = (options, __opts) => {
const newOptions = { ...resolvedOptions, inputValidator }
return createServerFn(undefined, newOptions) as any
},
+ rawHandler: (...args) => {
+ // This function signature changes due to AST transformations
+ // in the babel plugin. We need to cast it to the correct
+ // function signature post-transformation
+ const [extractedFn, serverFn] = args as unknown as [
+ CompiledFetcherFn,
+ RawServerFn,
+ ]
+
+ // Keep the original function around so we can use it
+ // in the server environment
+ const newOptions = {
+ ...resolvedOptions,
+ extractedFn,
+ serverFn: serverFn as any,
+ rawHandler: true,
+ }
+
+ const resolvedMiddleware = [
+ ...(newOptions.middleware || []),
+ serverFnBaseToMiddleware(newOptions),
+ ]
+
+ // We want to make sure the new function has the same
+ // properties as the original function
+
+ return Object.assign(
+ async (opts?: CompiledFetcherFnOptions) => {
+ // Start by executing the client-side middleware chain
+ return executeMiddleware(resolvedMiddleware, 'client', {
+ ...extractedFn,
+ ...newOptions,
+ data: opts?.data as any,
+ headers: opts?.headers,
+ signal: opts?.signal,
+ context: {},
+ }).then((d) => {
+ if (d.error) throw d.error
+ return d.result
+ })
+ },
+ {
+ // This copies over the URL, function ID
+ ...extractedFn,
+ __rawHandler: true,
+ // The extracted function on the server-side calls
+ // this function
+ __executeServer: async (opts: any, signal: AbortSignal) => {
+ const startContext = getStartContextServerOnly()
+ const serverContextAfterGlobalMiddlewares =
+ startContext.contextAfterGlobalMiddlewares
+ const ctx = {
+ ...extractedFn,
+ ...opts,
+ context: {
+ ...serverContextAfterGlobalMiddlewares,
+ ...opts.context,
+ },
+ signal,
+ request: startContext.request,
+ }
+
+ return executeMiddleware(resolvedMiddleware, 'server', ctx).then(
+ (d) => ({
+ // Only send the result and sendContext back to the client
+ result: d.result,
+ error: d.error,
+ context: d.sendContext,
+ }),
+ )
+ },
+ },
+ ) as any
+ },
handler: (...args) => {
// This function signature changes due to AST transformations
// in the babel plugin. We need to cast it to the correct
@@ -315,6 +389,12 @@ export type ServerFn<
ctx: ServerFnCtx,
) => ServerFnReturnType
+export type RawServerFn = (ctx: {
+ request: Request
+ signal: AbortSignal
+ context: Expand>
+}) => ServerFnReturnType
+
export interface ServerFnCtx<
TRegister,
TMethod,
@@ -356,6 +436,7 @@ export type ServerFnBaseOptions<
TResponse
>
functionId: string
+ rawHandler?: boolean
}
export type ValidateValidatorInput<
@@ -513,6 +594,9 @@ export interface ServerFnHandler<
TNewResponse
>,
) => Fetcher
+ rawHandler: (
+ fn?: RawServerFn,
+ ) => Fetcher
}
export interface ServerFnBuilder
@@ -611,6 +695,7 @@ export type ServerFnMiddlewareOptions = {
sendContext?: any
context?: any
functionId: string
+ request?: Request
}
export type ServerFnMiddlewareResult = ServerFnMiddlewareOptions & {
@@ -718,7 +803,19 @@ function serverFnBaseToMiddleware(
},
server: async ({ next, ...ctx }) => {
// Execute the server function
- const result = await options.serverFn?.(ctx as TODO)
+ let result: any
+ if (options.rawHandler) {
+ // For raw handlers, pass request, signal, and context
+ const ctxWithRequest = ctx as typeof ctx & { request: Request }
+ result = await (options.serverFn as any)?.({
+ request: ctxWithRequest.request,
+ signal: ctx.signal,
+ context: ctx.context,
+ })
+ } else {
+ // For normal handlers, pass the full context
+ result = await options.serverFn?.(ctx as TODO)
+ }
return next({
...ctx,
diff --git a/packages/start-plugin-core/src/create-server-fn-plugin/compiler.ts b/packages/start-plugin-core/src/create-server-fn-plugin/compiler.ts
index f00f89dbb8a..4b0011114f4 100644
--- a/packages/start-plugin-core/src/create-server-fn-plugin/compiler.ts
+++ b/packages/start-plugin-core/src/create-server-fn-plugin/compiler.ts
@@ -35,7 +35,7 @@ const LookupSetup: Record<
LookupKind,
{ candidateCallIdentifier: Set }
> = {
- ServerFn: { candidateCallIdentifier: new Set(['handler']) },
+ ServerFn: { candidateCallIdentifier: new Set(['handler', 'rawHandler']) },
Middleware: {
candidateCallIdentifier: new Set(['server', 'client', 'createMiddlewares']),
},
diff --git a/packages/start-plugin-core/src/create-server-fn-plugin/handleCreateServerFn.ts b/packages/start-plugin-core/src/create-server-fn-plugin/handleCreateServerFn.ts
index 75ebfecdbff..3448868f29d 100644
--- a/packages/start-plugin-core/src/create-server-fn-plugin/handleCreateServerFn.ts
+++ b/packages/start-plugin-core/src/create-server-fn-plugin/handleCreateServerFn.ts
@@ -16,7 +16,12 @@ export function handleCreateServerFn(
// the validator, handler, and middleware methods. Check to make sure they
// are children of the createServerFn call expression.
- const validMethods = ['middleware', 'inputValidator', 'handler'] as const
+ const validMethods = [
+ 'middleware',
+ 'inputValidator',
+ 'handler',
+ 'rawHandler',
+ ] as const
type ValidMethods = (typeof validMethods)[number]
const callExpressionPaths: Record<
ValidMethods,
@@ -25,6 +30,7 @@ export function handleCreateServerFn(
middleware: null,
inputValidator: null,
handler: null,
+ rawHandler: null,
}
const rootCallExpression = getRootCallExpression(path)
@@ -84,15 +90,30 @@ export function handleCreateServerFn(
// First, we need to move the handler function to a nested function call
// that is applied to the arguments passed to the server function.
- const handlerFnPath = callExpressionPaths.handler?.get(
+ // Support both handler and rawHandler
+ const handlerMethod = callExpressionPaths.handler
+ ? 'handler'
+ : callExpressionPaths.rawHandler
+ ? 'rawHandler'
+ : null
+
+ if (!handlerMethod) {
+ throw codeFrameError(
+ opts.code,
+ path.node.callee.loc!,
+ `createServerFn must be called with a "handler" or "rawHandler" property!`,
+ )
+ }
+
+ const handlerFnPath = callExpressionPaths[handlerMethod]?.get(
'arguments.0',
) as babel.NodePath
- if (!callExpressionPaths.handler || !handlerFnPath.node) {
+ if (!handlerFnPath.node) {
throw codeFrameError(
opts.code,
path.node.callee.loc!,
- `createServerFn must be called with a "handler" property!`,
+ `createServerFn must be called with a "handler" or "rawHandler" property!`,
)
}
@@ -151,6 +172,6 @@ export function handleCreateServerFn(
)
if (opts.env === 'server') {
- callExpressionPaths.handler.node.arguments.push(handlerFn)
+ callExpressionPaths[handlerMethod]!.node.arguments.push(handlerFn)
}
}
diff --git a/packages/start-plugin-core/src/create-server-fn-plugin/plugin.ts b/packages/start-plugin-core/src/create-server-fn-plugin/plugin.ts
index 25771328ddc..cfff5d2a2e0 100644
--- a/packages/start-plugin-core/src/create-server-fn-plugin/plugin.ts
+++ b/packages/start-plugin-core/src/create-server-fn-plugin/plugin.ts
@@ -85,8 +85,8 @@ export function createServerFnPlugin(
},
code: {
// TODO apply this plugin with a different filter per environment so that .createMiddleware() calls are not scanned in server env
- // only scan files that mention `.handler(` | `.createMiddleware()`
- include: [/\.\s*handler\(/, /\.\s*createMiddleware\(\)/],
+ // only scan files that mention `.handler(` | `.rawHandler(` | `.createMiddleware()`
+ include: [/\.\s*handler\(/, /\.\s*rawHandler\(/, /\.\s*createMiddleware\(\)/],
},
},
async handler(code, id) {
diff --git a/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts b/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts
index 325459b694f..a6e884404a4 100644
--- a/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts
+++ b/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts
@@ -95,6 +95,106 @@ describe('createServerFn compiles correctly', async () => {
`)
})
+ test('should work with rawHandler', async () => {
+ const code = `
+ import { createServerFn } from '@tanstack/react-start'
+ const myRawFunc = ({ request, signal }) => {
+ return new Response('hello from raw handler')
+ }
+ const myServerFn = createServerFn({ method: 'POST' }).rawHandler(myRawFunc)`
+
+ const compiledResultClient = await compile({
+ id: 'test.ts',
+ code,
+ env: 'client',
+ })
+
+ const compiledResultServer = await compile({
+ id: 'test.ts',
+ code,
+ env: 'server',
+ })
+
+ expect(compiledResultClient!.code).toMatchInlineSnapshot(`
+ "import { createServerFn } from '@tanstack/react-start';
+ const myServerFn = createServerFn({
+ method: 'POST'
+ }).rawHandler((opts, signal) => {
+ "use server";
+
+ return myServerFn.__executeServer(opts, signal);
+ });"
+ `)
+
+ expect(compiledResultServer!.code).toMatchInlineSnapshot(`
+ "import { createServerFn } from '@tanstack/react-start';
+ const myRawFunc = ({
+ request,
+ signal
+ }) => {
+ return new Response('hello from raw handler');
+ };
+ const myServerFn = createServerFn({
+ method: 'POST'
+ }).rawHandler((opts, signal) => {
+ "use server";
+
+ return myServerFn.__executeServer(opts, signal);
+ }, myRawFunc);"
+ `)
+ })
+
+ test('should work with inline rawHandler', async () => {
+ const code = `
+ import { createServerFn } from '@tanstack/react-start'
+ export const uploadFile = createServerFn({ method: 'POST' }).rawHandler(async ({ request, signal }) => {
+ const contentType = request.headers.get('content-type')
+ return new Response(JSON.stringify({ contentType }))
+ })`
+
+ const compiledResultClient = await compile({
+ id: 'test.ts',
+ code,
+ env: 'client',
+ })
+
+ const compiledResultServer = await compile({
+ id: 'test.ts',
+ code,
+ env: 'server',
+ })
+
+ expect(compiledResultClient!.code).toMatchInlineSnapshot(`
+ "import { createServerFn } from '@tanstack/react-start';
+ export const uploadFile = createServerFn({
+ method: 'POST'
+ }).rawHandler((opts, signal) => {
+ "use server";
+
+ return uploadFile.__executeServer(opts, signal);
+ });"
+ `)
+
+ expect(compiledResultServer!.code).toMatchInlineSnapshot(`
+ "import { createServerFn } from '@tanstack/react-start';
+ export const uploadFile = createServerFn({
+ method: 'POST'
+ }).rawHandler((opts, signal) => {
+ "use server";
+
+ return uploadFile.__executeServer(opts, signal);
+ }, async ({
+ request,
+ signal
+ }) => {
+ const contentType = request.headers.get('content-type');
+ return new Response(JSON.stringify({
+ contentType
+ }));
+ });"
+ `)
+ })
+
test('should use dce by default', async () => {
const code = `
import { createServerFn } from '@tanstack/react-start'
diff --git a/packages/start-server-core/src/server-functions-handler.ts b/packages/start-server-core/src/server-functions-handler.ts
index ec9042ec473..3c5d26570a3 100644
--- a/packages/start-server-core/src/server-functions-handler.ts
+++ b/packages/start-server-core/src/server-functions-handler.ts
@@ -66,6 +66,11 @@ export const handleServerAction = async ({
const response = await (async () => {
try {
let result = await (async () => {
+ // For raw handlers, skip FormData parsing and pass the request directly
+ if ((action as any).__rawHandler) {
+ return await action({ context }, signal)
+ }
+
// FormData
if (
formDataContentTypes.some(