bug: multipart/form-data procedures fail behind express 5 body parsers ("Failed to parse body as FormData")
Provide environment information
System:
OS: macOS 26.4.1
CPU: (10) arm64 Apple M2 Pro
Memory: 95.31 MB / 32.00 GB
Shell: 5.9 - /bin/zsh
Binaries:
Node: 24.6.0 - /opt/homebrew/bin/node
npm: 11.5.1 - /opt/homebrew/bin/npm
pnpm: 10.6.5 - /opt/homebrew/bin/pnpm
Browsers:
Chrome: 147.0.7727.116
Safari: 26.4
npmPackages:
@nestjs/platform-express: ^11.0.0 => 11.1.28
@tanstack/react-query: ^5.66.0 => 5.101.2
@trpc/client: ^11.0.0 => 11.18.0
@trpc/server: ^11.0.0 => 11.18.0
express: ^5.2.1 => 5.2.1
react: ^19.2.4 => 19.2.7
typescript: ^5.7.2 => 5.9.3Describe the bug
Any procedure with a FormData input fails with TRPCError: Failed to parse body as FormData when the express app has global body parsers registered (express.json() / express.urlencoded()) — the default shape of every express 5 and NestJS application.
Express 5's body-parser defines the req.body property (with the value undefined) even on content types it skips, such as multipart/form-data. incomingMessageToRequest's 'body' in req presence check then treats the request as pre-parsed and builds a body-less fetch Request — discarding the unread multipart stream — so the multipart content-type handler's request.formData() throws.
Expected: the adapter streams the untouched body, exactly as it does when no body parser is registered (removing express.json() makes the same request succeed).
Link to reproduction
Single-file repro below; a regression test reproducing it inside this repo is included in the accompanying PR.
To reproduce
import { initTRPC } from '@trpc/server';
import { createExpressMiddleware } from '@trpc/server/adapters/express';
import express from 'express';
import { z } from 'zod';
const t = initTRPC.create();
const router = t.router({
upload: t.procedure
.input(z.custom<FormData>((v) => (v as object).toString() === '[object FormData]'))
.mutation(({ input }) => ({ id: input.get('id') })),
});
const app = express();
app.use(express.json()); // ← remove this line and the mutation succeeds
app.use('/trpc', createExpressMiddleware({ router }));
app.listen(4000, async () => {
const fd = new FormData();
fd.set('id', 'bar');
const res = await fetch('http://localhost:4000/trpc/upload', { method: 'POST', body: fd });
console.log(res.status, await res.text());
// 400 {"error":{"message":"Failed to parse body as FormData." ...
});Additional information
Root cause is in packages/server/src/adapters/node-http/incomingMessageToRequest.ts (createBody):
if ('body' in req) {
if (req.body === undefined) {
return undefined; // ← Request is built with NO body, but the stream was never read
}
...body-parser 2.x (express 5) defines req.body on every request it sees — including ones it skips — so the presence check misfires. The pre-parsed string/object paths are unaffected (JSON procedures work fine); only content types the parser skips are broken.
A fix is ready: when the property exists but is undefined, fall through to streaming unless the stream was actually consumed (req.readableEnded), which preserves the existing "consumed body" behavior covered by the current unit tests.
Contributing
- ♂️ Yes, I'd be down to file a PR fixing this bug!
Source: trpc/trpc