non-json-content-types

v2026.09.25

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

GitHub
Install command
npx skhub add trpc/non-json-content-types
Markdown
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, procedures
  • links -- splitLink configuration for routing non-JSON requests
  • validators -- z.instanceof(FormData) for FormData validation
  • adapter-express -- Express-specific body parser considerations
Discovery
Tags

No tags published for this skill.

Version
Latest version metadata

Version

v2026.09.25

Published

Sep 25, 2026

Category

Uncategorized

License

MIT

Source path

packages/server/skills/non-json-content-types

Default branch

main

Latest commit

8b649ad

Tree SHA

6d43a32