
Description
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. FormData and binary inputs only work with mutations (POST).
SKILL.md
tRPC -- Non-JSON Content Types
Setup
Server:
// server/trpc.ts
import { initTRPC } from '@trpc/server';
const t = initTRPC.create();
export const router = t.router;
export const publicProcedure = t.procedure;
// server/appRouter.ts
import { octetInputParser } from '@trpc/server/http';
import { z } from 'zod';
import { publicProcedure, router } from './trpc';
export const appRouter = router({
uploadForm: publicProcedure
.input(z.instanceof(FormData))
.mutation(({ input }) => {
const name = input.get('name');
return { greeting: `Hello ${name}` };
}),
uploadFile: publicProcedure.input(octetInputParser).mutation(({ input }) => {
// input is a ReadableStream
return { valid: true };
}),
});
export type AppRouter = typeof appRouter;
Client:
// client/index.ts
import {
createTRPCClient,
httpBatchLink,
httpLink,
isNonJsonSerializable,
splitLink,
} from '@trpc/client';
import type { AppRouter } from '../server/appRouter';
const url = 'http://localhost:3000';
const trpc = createTRPCClient<AppRouter>({
links: [
splitLink({
condition: (op) => isNonJsonSerializable(op.input),
true: httpLink({ url }),
false: httpBatchLink({ url }),
}),
],
});
Core Patterns
FormData mutation
// server/appRouter.ts
import { z } from 'zod';
import { publicProcedure, router } from './trpc';
export const appRouter = router({
createPost: publicProcedure
.input(z.instanceof(FormData))
.mutation(({ input }) => {
const title = input.get('title') as string;
const body = input.get('body') as string;
return { id: '1', title, body };
}),
});
// client usage
const form = new FormData();
form.append('title', 'Hello');
form.append('body', 'World');
const result = await trpc.createPost.mutate(form);
Binary file upload with octetInputParser
// server/appRouter.ts
import { octetInputParser } from '@trpc/server/http';
import { publicProcedure, router } from './trpc';
export const appRouter = router({
upload: publicProcedure
.input(octetInputParser)
.mutation(async ({ input }) => {
const reader = input.getReader();
let totalBytes = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
totalBytes += value.byteLength;
}
return { totalBytes };
}),
});
// client usage
const file = new File(['hello world'], 'test.txt', { type: 'text/plain' });
const result = await trpc.upload.mutate(file);
octetInputParser converts Blob, Uint8Array, and File inputs to a ReadableStream on the server.
Client splitLink with superjson transformer
import {
createTRPCClient,
httpBatchLink,
httpLink,
isNonJsonSerializable,
splitLink,
} from '@trpc/client';
import superjson from 'superjson';
import type { AppRouter } from '../server/appRouter';
const url = 'http://localhost:3000';
const trpc = createTRPCClient<AppRouter>({
links: [
splitLink({
condition: (op) => isNonJsonSerializable(op.input),
true: httpLink({
url,
transformer: {
serialize: (data) => data,
deserialize: (data) => superjson.deserialize(data),
},
}),
false: httpBatchLink({
url,
transformer: superjson,
}),
}),
],
});
When using a transformer, the non-JSON httpLink needs a custom transformer that skips serialization for the request (FormData/binary cannot be transformed) but deserializes the response.
Common Mistakes
HIGH Using httpBatchLink for FormData requests
Wrong:
import { createTRPCClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from '../server/appRouter';
const trpc = createTRPCClient<AppRouter>({
links: [httpBatchLink({ url: 'http://localhost:3000' })],
});
Correct:
import {
createTRPCClient,
httpBatchLink,
httpLink,
isNonJsonSerializable,
splitLink,
} from '@trpc/client';
import type { AppRouter } from '../server/appRouter';
const url = 'http://localhost:3000';
const trpc = createTRPCClient<AppRouter>({
links: [
splitLink({
condition: (op) => isNonJsonSerializable(op.input),
true: httpLink({ url }),
false: httpBatchLink({ url }),
}),
],
});
FormData and binary inputs are not batchable; use splitLink with isNonJsonSerializable() to route them through httpLink.
Source: www/docs/server/non-json-content-types.md
HIGH Global body parser intercepting FormData before tRPC
Wrong:
import * as trpcExpress from '@trpc/server/adapters/express';
import express from 'express';
import { appRouter } from './appRouter';
const app = express();
app.use(express.json());
app.use('/trpc', trpcExpress.createExpressMiddleware({ router: appRouter }));
Correct:
import * as trpcExpress from '@trpc/server/adapters/express';
import express from 'express';
import { appRouter } from './appRouter';
const app = express();
app.use('/api', express.json());
app.use('/trpc', trpcExpress.createExpressMiddleware({ router: appRouter }));
A global express.json() middleware consumes the request body before tRPC can read it; scope body parsing to non-tRPC routes only.
Source: www/docs/server/non-json-content-types.md
HIGH FormData only works with mutations
FormData and binary inputs are only supported for mutations (POST requests). Using them with .query() throws an error because queries use HTTP GET which cannot carry a request body.
Source: www/docs/server/non-json-content-types.md
See Also
server-setup-- initTRPC, routers, procedureslinks-- splitLink configuration for routing non-JSON requestsvalidators-- z.instanceof(FormData) for FormData validationadapter-express-- Express-specific body parser considerations
More skills from the trpc repository
View all 24 skillsadapter-aws-lambda
deploy tRPC APIs on AWS Lambda
Jul 18API DevelopmentAWSBackendtRPCadapter-express
mount tRPC as Express middleware
Jul 18API DevelopmentBackendExpresstRPCadapter-fastify
mount tRPC as a Fastify plugin
Jul 18API DevelopmentBackendFastifytRPCadapter-fetch
deploy tRPC on edge runtimes
Jul 18CloudflareDeploymentEdgetRPC +1adapter-standalone
mount tRPC on Node.js servers
Jul 18API DevelopmentBackendNode.jstRPCauth
implement authentication in tRPC applications
Jul 18AuthAuthenticationBackendtRPC
More from tRPC
View publishercaching
configure HTTP caching for tRPC queries
trpc
Jul 18BackendCachingPerformancetRPCclient-setup
set up vanilla tRPC clients
trpc
Jul 18API DevelopmentFrontendtRPCerror-handling
implement typed error handling in tRPC
trpc
Jul 18BackendDebuggingtRPCTypeScriptlinks
configure tRPC client link chains
trpc
Jul 18API DevelopmentFrontendtRPCmiddlewares
compose middleware for tRPC procedures
trpc
Jul 18BackendMiddlewaretRPCnextjs-app-router
implement tRPC in Next.js App Router
trpc
Jul 18BackendFrontendNext.jstRPC +1