9.4 KiB
tRPC File Upload Flow (Documenso)
This document explains how Documenso uploads files via tRPC using multipart/form-data, from client to server validation and persistence.
1) Client: send FormData to a tRPC mutation
In apps/remix/app/components/general/envelope/envelope-upload-button.tsx, the UI builds a FormData payload and calls the mutation directly:
payloadis appended as a JSON string.- each file is appended with the same key (
files) so it becomes a repeatable field.
Pattern:
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
for (const file of files) {
formData.append('files', file);
}
await createEnvelope(formData);
Important details:
- The mutation call is
trpc.envelope.create.useMutation()and acceptsFormDatafor this route. - The client also does pre-check UX (limits, max files, size messaging), but server still enforces authoritative validation.
2) Route contract: multipart + zod-form-data
In packages/trpc/server/envelope-router/create-envelope.types.ts:
- OpenAPI metadata explicitly marks the route as multipart:
contentTypes: ['multipart/form-data']
- request schema uses a custom
zodFormData(...)wrapper. payloadis parsed from JSON withzfd.json(...).filesis parsed as repeated files withzfd.repeatableOfType(zfd.file()).
Pattern:
export const ZCreateEnvelopeRequestSchema = zodFormData({
payload: zfd.json(ZCreateEnvelopePayloadSchema),
files: zfd.repeatableOfType(zfd.file()),
});
This gives a strongly typed input:
input.payloadis a validated object.input.filesis a validatedFile[].
3) Why zodFormData exists
In packages/trpc/utils/zod-form-data.ts, zodFormData is a thin preprocess helper:
- if input is
FormData, it converts it into a plain object. - duplicate keys become arrays (
getAll(key)behavior). - then runs
z.object(schema)validation.
Reason in code comments:
- it replaces
zfd.formData()due to pipeline/openapi edge cases whereundefinedcan surface and break parsing.
So this helper is a compatibility layer that still behaves like normal form-data parsing for Zod.
Full file:
import type { ZodRawShape } from 'zod';
import z from 'zod';
/**
* This helper takes the place of the `z.object` at the root of your schema.
* It wraps your schema in a `z.preprocess` that extracts all the data out of a `FormData`
* and transforms it into a regular object.
* If the `FormData` contains multiple entries with the same field name,
* it will automatically turn that field into an array.
*
* This is used instead of `zfd.formData()` because it receives `undefined`
* somewhere in the pipeline of our openapi schema generation and throws
* an error. This provides the same functionality as `zfd.formData()` but
* can be considered somewhat safer.
*/
export const zodFormData = <T extends ZodRawShape>(schema: T) => {
return z.preprocess((data) => {
if (data instanceof FormData) {
const formData: Record<string, unknown> = {};
for (const key of data.keys()) {
const values = data.getAll(key);
formData[key] = values.length > 1 ? values : values[0];
}
return formData;
}
return data;
}, z.object(schema));
};
4) Server mutation: validate and process each uploaded file
In packages/trpc/server/envelope-router/create-envelope.ts:
- input is already schema-validated (
.input(ZCreateEnvelopeRequestSchema)). - server enforces limits and file rules:
- monthly doc limit
- max envelope item count
- MIME must start with
application/pdf
- each uploaded file is processed:
- convert to buffer via
await file.arrayBuffer() - optionally inject form values into PDF
- normalize PDF
- extract placeholders
- upload file bytes server-side (
putPdfFileServerSide)
- convert to buffer via
- resulting uploaded file IDs (
documentDataId) are attached to envelope items. - envelope is created with those items + recipient mapping logic.
Key loop:
const envelopeItems = await Promise.all(
files.map(async (file) => {
let pdf = Buffer.from(await file.arrayBuffer());
// ... optional transform + normalize + placeholder extraction
const { id: documentDataId } = await putPdfFileServerSide({
name: file.name,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(cleanedPdf),
});
return { title: file.name, documentDataId, placeholders };
}),
);
5) Multipart parsing infrastructure (critical)
The multipart body support is implemented in packages/trpc/utils/openapi-fetch-handler.ts.
For multipart requests, it:
- reads
req.formData(), - converts entries into a plain object (accumulating repeated keys into arrays),
- supports
key[]sent by some SDKs by normalizing tokey, - rewrites request
content-typetoapplication/jsonfor the OpenAPI node handler interop, - and passes parsed body downstream.
This is why multipart routes can be validated by normal Zod/tRPC schemas in this codebase.
Key multipart handling code:
const CONTENT_TYPE_JSON = 'application/json';
const CONTENT_TYPE_URLENCODED = 'application/x-www-form-urlencoded';
const CONTENT_TYPE_MULTIPART = 'multipart/form-data';
const getMultipartBody = async (req: Request) => {
const formData = await req.formData();
const data: Record<string, unknown> = {};
for (const [key, value] of formData.entries()) {
// !: Handles cases where our generated SDKs send key[] syntax for arrays.
const normalizedKey = key.endsWith('[]') ? key.slice(0, -2) : key;
if (data[normalizedKey] === undefined) {
data[normalizedKey] = value;
} else if (Array.isArray(data[normalizedKey])) {
data[normalizedKey].push(value);
} else {
data[normalizedKey] = [data[normalizedKey], value];
}
}
return data;
};
const getRequestBody = async (req: Request) => {
try {
const contentType = req.headers.get('content-type') || '';
if (contentType.includes(CONTENT_TYPE_JSON)) {
return {
isValid: true,
// Use JSON.parse instead of req.json() because req.json() does not throw on invalid JSON
data: JSON.parse(await req.text()),
};
}
if (contentType.includes(CONTENT_TYPE_URLENCODED)) {
return {
isValid: true,
data: await getUrlEncodedBody(req),
};
}
// Handle multipart/form-data by parsing as FormData and converting to a plain object.
// This mirrors how URL-encoded data is structured, allowing tRPC to validate it normally.
// The content-type header is rewritten to application/json later via the request proxy
// because createOpenApiNodeHttpHandler aborts on any bodied request that isn't application/json.
if (contentType.includes(CONTENT_TYPE_MULTIPART)) {
return {
isValid: true,
data: await getMultipartBody(req),
};
}
return {
isValid: true,
data: req.body,
};
} catch (err) {
return {
isValid: false,
cause: err,
};
}
};
Header rewrite and proxy behavior:
const createRequestProxy = async (req: Request, url?: string) => {
const body = await getRequestBody(req);
const originalContentType = req.headers.get('content-type') || '';
const isMultipart = originalContentType.includes(CONTENT_TYPE_MULTIPART);
return new Proxy(req, {
get: (target, prop) => {
switch (prop) {
case 'url':
return url ?? target.url;
case 'body': {
if (!body.isValid) {
throw new TRPCError({
code: 'PARSE_ERROR',
message: 'Failed to parse request body',
cause: body.cause,
});
}
return body.data;
}
case 'headers': {
const headers = new Headers(target.headers);
// Rewrite content-type header for multipart requests to application/json.
// This is necessary because `createOpenApiNodeHttpHandler` aborts on any bodied
// request that isn't application/json. Since we've already parsed the multipart
// data into a plain object above, this is safe to do.
if (isMultipart) {
headers.set('content-type', CONTENT_TYPE_JSON);
}
return headers;
}
default:
return (target as unknown as Record<string | number | symbol, unknown>)[prop];
}
},
});
};
6) Porting checklist for another project
Use this exact checklist:
- Client mutation
- Build
FormData. - Append structured data as JSON string (for example
payload). - Append each file under a repeatable field key (for example
files).
- Build
- Route meta
- Mark route with
contentTypes: ['multipart/form-data'].
- Mark route with
- Schema
- Parse JSON field with
zfd.json(...). - Parse repeated files with
zfd.repeatableOfType(zfd.file()). - Wrap root with a form-data preprocessor (
zodFormDatapattern).
- Parse JSON field with
- Request adapter
- Ensure your server adapter can parse multipart into plain object + file values before schema validation.
- Server safety checks
- Enforce limits/count/type server-side, not just UI.
- File processing
- Read each file (
arrayBuffer), transform as needed, upload, persist returned storage IDs.
- Read each file (
7) Common pitfalls
- Relying only on client-side file restrictions.
- Forgetting repeatable parsing for multiple files.
- Missing multipart handling in the HTTP/OpenAPI adapter layer.
- Using mismatched field names between client FormData and Zod schema keys.