non-json-content-types
Handle FormData, file uploads, Blob, Uint8Array, and ReadableStream inputs in tRPC mutations. Use octetInputParser from @trpc/server/http for binary data. Route non-JSON requests with splitLink and isNonJsonSerializable() from @trpc/client, since batching links send them as `{}`. FormData and binary inputs only work with mutations (POST) and skip the transformer. Keep Express or Next.js body parsers off tRPC routes.
Content Types
From https://trpc.io/docs/server/non-json-content-types
tRPC supports multiple content types as procedure inputs: JSON-serializable data, FormData, File, Blob, and other binary types.
JSON (Default)
By default, tRPC sends and receives JSON-serializable data. No extra configuration is needed — any input that can be serialized to JSON works out of the box with all links (httpLink, httpBatchLink, httpBatchStreamLink).
import { z } from 'zod';
export const t = initTRPC.create();
const publicProcedure = t.procedure;
export const appRouter = t.router({
hello: publicProcedure.input(z.object({ name: z.string() })).query((opts) => {
return { greeting: `Hello ${opts.input.name}` };
}),
});Non-JSON Content Types
In addition to JSON, tRPC can use FormData, File, and other binary types as procedure inputs.
Client Setup
Info
While tRPC natively supports several non-JSON serializable types, your client may need a little link configuration to support them depending on your setup.
httpLink supports non-JSON content types out of the box — if you're only using this link, your existing setup should work immediately.
import { createTRPCClient, httpLink } from '@trpc/client';
import type { AppRouter } from './server';
createTRPCClient<AppRouter>({
links: [
httpLink({
url: 'http://localhost:2022',
}),
],
});However, not all links support these content types. If you're using httpBatchLink or httpBatchStreamLink, you will need to include a splitLink and route requests based on the content type.
import {
createTRPCClient,
httpBatchLink,
httpLink,
isNonJsonSerializable,
splitLink,
} from '@trpc/client';
import type { AppRouter } from './server';
const url = 'http://localhost:2022';
createTRPCClient<AppRouter>({
links: [
splitLink({
condition: (op) => isNonJsonSerializable(op.input),
true: httpLink({
url,
}),
false: httpBatchLink({
url,
}),
}),
],
});If you are using transformer in your tRPC server, TypeScript requires that your tRPC client link defines transformer as well. Use this example as a base:
import {
createTRPCClient,
httpBatchLink,
httpLink,
isNonJsonSerializable,
splitLink,
} from '@trpc/client';
import superjson from 'superjson';
import type { AppRouter } from './server';
const url = 'http://localhost:2022';
createTRPCClient<AppRouter>({
links: [
splitLink({
condition: (op) => isNonJsonSerializable(op.input),
true: httpLink({
url,
transformer: {
// request - convert data before sending to the tRPC server
serialize: (data) => data,
// response - convert the tRPC response before using it in client
deserialize: (data) => superjson.deserialize(data), // or your other transformer
},
}),
false: httpBatchLink({
url,
transformer: superjson, // or your other transformer
}),
}),
],
});Server Setup
Info
When a request is handled by tRPC, it takes care of parsing the request body based on the Content-Type header of the request. If you encounter errors like Failed to parse body as XXX, make sure that your server (e.g., Express, Next.js) isn't parsing the request body before tRPC handles it:
- Express: see Body parsers.
- Next.js Pages Router: turn off Next's body parser in the tRPC API route with export const config = { api: { bodyParser: false } }, as in our FormData example.
FormData Input
FormData is natively supported, and for more advanced usage you could also combine this with a library like zod-form-data to validate inputs in a type-safe way.
import { z } from 'zod';
export const t = initTRPC.create();
const publicProcedure = t.procedure;
export const appRouter = t.router({
hello: publicProcedure.input(z.instanceof(FormData)).mutation((opts) => {
const data = opts.input;
return {
greeting: `Hello ${data.get('name')}`,
};
}),
});On the client, pass a FormData instance to the mutation:
const form = new FormData();
form.append('name', 'Bilbo');
const result = await client.hello.mutate(form);For a more advanced code sample you can see our example project here
File and other Binary Type Inputs
tRPC converts many octet content types to a ReadableStream which can be consumed in a procedure. Currently these are Blob Uint8Array and File.
import { octetInputParser } from '@trpc/server/http';
export const t = initTRPC.create();
const publicProcedure = t.procedure;
export const appRouter = t.router({
upload: publicProcedure.input(octetInputParser).mutation(async (opts) => {
const data = opts.input;
const reader = data.getReader();
let bytes = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
bytes += value.byteLength;
}
return { bytes };
}),
});On the client, pass a File, Blob or Uint8Array to the mutation:
const file = new File(['hello world'], 'hello.txt', { type: 'text/plain' });
await client.upload.mutate(file);Common mistakes
- Using a query. FormData and binary inputs are sent as the body of a POST request, so they only work with mutations: httpLink throws FormData is only supported for mutations for a query.
- Sending them through a batching link. httpBatchLink, httpBatchStreamLink and wsLink serialize inputs as JSON, which turns a FormData or File into {}, so input validation fails. Route them to httpLink with a splitLink.
- Transforming the request. tRPC passes FormData and binary inputs to your procedure as they are, without your transformer. If you use one, give the non-JSON httpLink a transformer that leaves the request alone, as shown above.
- Letting your server parse the body first. See Server Setup.
Body parsers
tRPC parses request bodies itself, so tRPC routes don't need express.json(). If a body parser runs before tRPC anyway, tRPC uses the req.body it parsed, which works for JSON. For FormData and binary inputs it depends on your Express version:
- Express 5: express.json() and express.urlencoded() leave other content types unread, so tRPC still reads them.
- Express 4: the parsers set req.body to {} on content types they skip, so tRPC receives {} instead of the form data or file.
- In both versions, a parser that reads the body you upload, like express.raw() for application/octet-stream, breaks binary inputs.
To be safe, register body parsers only on the routes that need them:
import * as trpcExpress from '@trpc/server/adapters/express';
import express from 'express';
import { appRouter } from './router';
const app = express();
app.use('/api', express.json()); // only for your own `/api/*` routes
app.post('/api/webhook', (req, res) => {
res.json({ received: req.body });
});
app.use('/trpc', trpcExpress.createExpressMiddleware({ router: appRouter }));
app.listen(4000);From https://trpc.io/docs/server/adapters/express#body-parsers
Related skills
- server-setup (https://trpc.io/docs/server/overview): npx @tanstack/intent@latest load @trpc/server#server-setup
- links (https://trpc.io/docs/client/links): npx @tanstack/intent@latest load @trpc/client#links