Skip to main content

Return Response in Procedures

· 7 min read
Alex / KATT 🐱
Creator of tRPC

tRPC now supports returning Response-objects directly from procedures, giving you full control over response handling.

Why custom Response objects?

Most applications won't need custom response types since tRPC provides powerful built-in features for common use cases:

However, sometimes you need more control over your HTTP responses than just sending JSON data:

  • Sending raw files to the client
  • Handling binary data
  • Integration with SDKs that return Responses, like Vercel AI SDK

Basic Example

Here's a simple example of returning a custom response:

server.ts
ts
const appRouter = router({
hello: publicProcedure.query(async () => {
return new Response('Hello World', {
headers: {
'content-type': 'text/plain',
},
});
}),
});
server.ts
ts
const appRouter = router({
hello: publicProcedure.query(async () => {
return new Response('Hello World', {
headers: {
'content-type': 'text/plain',
},
});
}),
});
client.ts
ts
const response = await client.hello.query();
 
response;
const response: Response
client.ts
ts
const response = await client.hello.query();
 
response;
const response: Response

Important Considerations & Type Safety

When using custom response types, keep in mind:

  • Responses cannot be batched - you must use httpLink()
  • The return type will always be Response and you lose the automatic type inference that tRPC usually provides, since you're working directly with the Web Response API. You'll need to handle parsing and type safety manually on the client side.
  • You are responsible for:
    • Properly formatting the response
    • Setting appropriate headers
    • Manual deserialization on the client
    • Maintaining type safety when needed

Setup

To use custom response types, you currently need to enable the feature in your tRPC configuration:

Setting up the client

When returning Response objects, you need to use httpLink since responses cannot be batched. However, for optimal performance, you'll likely want to use batching for your other procedures. You can achieve this using a splitLink:

ts
import { createTRPCClient, httpLink, splitLink, unstable_httpBatchStreamLink } from '@trpc/client';
import type { AppRouter } from './server';
 
const client = createTRPCClient<AppRouter>({
links: [
splitLink({
condition(op) {
return Boolean(op.context.skipBatch);
},
true: httpLink({
url: '/api',
}),
false: unstable_httpBatchStreamLink({
url: '/api',
}),
}),
],
});
 
// Usage
const response = await client.downloadFile.query(undefined, {
context: {
skipBatch: true,
},
});
 
console.log(response);
ts
import { createTRPCClient, httpLink, splitLink, unstable_httpBatchStreamLink } from '@trpc/client';
import type { AppRouter } from './server';
 
const client = createTRPCClient<AppRouter>({
links: [
splitLink({
condition(op) {
return Boolean(op.context.skipBatch);
},
true: httpLink({
url: '/api',
}),
false: unstable_httpBatchStreamLink({
url: '/api',
}),
}),
],
});
 
// Usage
const response = await client.downloadFile.query(undefined, {
context: {
skipBatch: true,
},
});
 
console.log(response);

Real World Examples

File Download

ts
const appRouter = router({
downloadFile: publicProcedure.query(() => {
return new Response('Hello World', {
headers: {
'Content-Type': 'text/plain',
'Content-Disposition': 'attachment; filename="hello.txt"',
},
});
}),
});
ts
const appRouter = router({
downloadFile: publicProcedure.query(() => {
return new Response('Hello World', {
headers: {
'Content-Type': 'text/plain',
'Content-Disposition': 'attachment; filename="hello.txt"',
},
});
}),
});

File Transformation

This example demonstrates:

  • Accepting file uploads via FormData
  • Processing the uploaded file
  • Returning the processed file with appropriate headers
  • Supporting different output formats
ts
import { publicProcedure, router } from './server';
import { processFile } from './utils';
import { z } from 'zod';
import { zfd } from 'zod-form-data';
 
const appRouter = router({
processFile: publicProcedure
.input(
zfd.formData({
file: zfd.file(),
})
)
.mutation(async (opts) => {
const processed = processFile(opts.input.file);
const processed: { stream: ReadableStream<Uint8Array>; format: string; }
 
return new Response(processed.stream, {
headers: {
'content-type': `image/${processed.format}`,
'content-disposition': `attachment; filename="processed.${processed.format}"`,
},
});
}),
});
ts
import { publicProcedure, router } from './server';
import { processFile } from './utils';
import { z } from 'zod';
import { zfd } from 'zod-form-data';
 
const appRouter = router({
processFile: publicProcedure
.input(
zfd.formData({
file: zfd.file(),
})
)
.mutation(async (opts) => {
const processed = processFile(opts.input.file);
const processed: { stream: ReadableStream<Uint8Array>; format: string; }
 
return new Response(processed.stream, {
headers: {
'content-type': `image/${processed.format}`,
'content-disposition': `attachment; filename="processed.${processed.format}"`,
},
});
}),
});

Integration with the useChat()-hook from the AI SDK

The benefit of using tRPC over a custom endpoint API endpoint is that you can use the same auth logic as your other backend procedures.

note

The example below is unsafely typecasting the input passed which most of Vercel's AI SDK examples does as well, but you can build a validator based on the type of Message or what you decide to pass using experimental_prepareRequestBody in the useChat().

server/_app.ts
tsx
import { protectedProcedure, router } from './trpc';
import { openai } from '@ai-sdk/openai';
import { streamText, Message } from 'ai';
import { z } from 'zod';
 
const appRouter = router({
ai: protectedProcedure
.input(z.unknown().transform(it => {
// typecast the raw input (see note above)
return it as Message[]
}))
.mutation(async (opts) => {
// You can access the user from the context as any other procedure
opts.ctx.user;
(property) user: User
const response = streamText({
model: openai('gpt-4o'),
system: 'You are a helpful assistant.',
messages: opts.input,
(property) ProcedureResolverOptions<object, object, { user: User; }, Message[]>.input: Message[]
});
 
return response.toDataStreamResponse();
}),
});
 
export type AppRouter = typeof appRouter;
server/_app.ts
tsx
import { protectedProcedure, router } from './trpc';
import { openai } from '@ai-sdk/openai';
import { streamText, Message } from 'ai';
import { z } from 'zod';
 
const appRouter = router({
ai: protectedProcedure
.input(z.unknown().transform(it => {
// typecast the raw input (see note above)
return it as Message[]
}))
.mutation(async (opts) => {
// You can access the user from the context as any other procedure
opts.ctx.user;
(property) user: User
const response = streamText({
model: openai('gpt-4o'),
system: 'You are a helpful assistant.',
messages: opts.input,
(property) ProcedureResolverOptions<object, object, { user: User; }, Message[]>.input: Message[]
});
 
return response.toDataStreamResponse();
}),
});
 
export type AppRouter = typeof appRouter;
components/Chat.tsx
tsx
import { useChat } from '@ai-sdk/react';
import { trpc } from '../utils/trpc';
import React from 'react';
 
export function Greeting() {
const utils = trpc.useUtils();
const chat = useChat({
fetch: (...args: any[]) => utils.client.ai.mutate(JSON.parse(args[1].body), {
signal: args[1].signal,
context: {
skipBatch: true,
}
}),
});
(property) user: User
 
return (
<>
{chat.messages.map((message) => (
(property) ProcedureResolverOptions<object, object, { user: User; }, Message[]>.input: Message[]
<div key={message.id}>
{message.role === 'user' ? 'User: ' : 'AI: '}
{message.content}
</div>
))}
 
<form onSubmit={chat.handleSubmit}>
<input name="prompt" value={chat.input} onChange={chat.handleInputChange} />
<button type="submit">Submit</button>
</form>
</>
);
}
components/Chat.tsx
tsx
import { useChat } from '@ai-sdk/react';
import { trpc } from '../utils/trpc';
import React from 'react';
 
export function Greeting() {
const utils = trpc.useUtils();
const chat = useChat({
fetch: (...args: any[]) => utils.client.ai.mutate(JSON.parse(args[1].body), {
signal: args[1].signal,
context: {
skipBatch: true,
}
}),
});
(property) user: User
 
return (
<>
{chat.messages.map((message) => (
(property) ProcedureResolverOptions<object, object, { user: User; }, Message[]>.input: Message[]
<div key={message.id}>
{message.role === 'user' ? 'User: ' : 'AI: '}
{message.content}
</div>
))}
 
<form onSubmit={chat.handleSubmit}>
<input name="prompt" value={chat.input} onChange={chat.handleInputChange} />
<button type="submit">Submit</button>
</form>
</>
);
}

Conclusion

Custom response types open up new possibilities for handling complex data scenarios in your tRPC applications. While they require a bit more manual handling compared to standard JSON responses, they provide the flexibility needed for advanced use cases like file downloads, streaming, and custom content types.

Remember to enable the experimental feature and configure your client appropriately to make the most of this powerful capability.