#7446·trpc

bug: multipart/form-data procedures fail behind express 5 body parsers ("Failed to parse body as FormData")

Author: noahisdaiCreated Jul 26, 2026Updated Jul 26, 2026

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.3

Describe 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

typescript
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):

typescript
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!