Return Response in Procedures
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:
- Batching via httpBatchLink streaming responses via httpBatchStreamLink
- Real-time data with subscriptions
- JSON responses with handling of custom data types using data transformers
- Uploading files and
FormData
as seen in thehttpLink
docs
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
Response
s, like Vercel AI SDK
Basic Example
Here's a simple example of returning a custom response:
server.tsts
constappRouter =router ({hello :publicProcedure .query (async () => {return newResponse ('Hello World', {headers : {'content-type': 'text/plain',},});}),});
server.tsts
constappRouter =router ({hello :publicProcedure .query (async () => {return newResponse ('Hello World', {headers : {'content-type': 'text/plain',},});}),});
client.tsts
constresponse = awaitclient .hello .query ();response ;
client.tsts
constresponse = awaitclient .hello .query ();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{ create TRPCClien t, 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',}),}),],});// Usageconst response = await client.downloadFile.query(undefined, {context: {skipBatch: true,},});console.log(response);
ts
import{ create TRPCClien t, 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',}),}),],});// Usageconst response = await client.downloadFile.query(undefined, {context: {skipBatch: true,},});console.log(response);
Real World Examples
File Download
ts
constappRouter =router ({downloadFile :publicProcedure .query (() => {return newResponse ('Hello World', {headers : {'Content-Type': 'text/plain','Content-Disposition': 'attachment; filename="hello.txt"',},});}),});
ts
constappRouter =router ({downloadFile :publicProcedure .query (() => {return newResponse ('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';constappRouter =router ({processFile :publicProcedure .input (zfd .formData ({file :zfd .file (),})).mutation (async (opts ) => {constprocessed =processFile (opts .input .file );return newResponse (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';constappRouter =router ({processFile :publicProcedure .input (zfd .formData ({file :zfd .file (),})).mutation (async (opts ) => {constprocessed =processFile (opts .input .file );return newResponse (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.
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.tstsx
import {protectedProcedure ,router } from './trpc';import {openai } from '@ai-sdk/openai';import {streamText ,Message } from 'ai';import {z } from 'zod';constappRouter =router ({ai :protectedProcedure .input (z .unknown ().transform (it => {// typecast the raw input (see note above)returnit asMessage []})).mutation (async (opts ) => {// You can access the user from the context as any other procedureopts .ctx .user ;constresponse =streamText ({model :openai ('gpt-4o'),system : 'You are a helpful assistant.',messages :opts .input ,});returnresponse .toDataStreamResponse ();}),});export typeAppRouter = typeofappRouter ;
server/_app.tstsx
import {protectedProcedure ,router } from './trpc';import {openai } from '@ai-sdk/openai';import {streamText ,Message } from 'ai';import {z } from 'zod';constappRouter =router ({ai :protectedProcedure .input (z .unknown ().transform (it => {// typecast the raw input (see note above)returnit asMessage []})).mutation (async (opts ) => {// You can access the user from the context as any other procedureopts .ctx .user ;constresponse =streamText ({model :openai ('gpt-4o'),system : 'You are a helpful assistant.',messages :opts .input ,});returnresponse .toDataStreamResponse ();}),});export typeAppRouter = typeofappRouter ;
components/Chat.tsxtsx
import { useChat } from '@ai-sdk/re act';import { trpc }from '../utils/trpc'; import React from 'react';export function Greeting() {const utils = trpc.useUtils();constchat = useChat({ fetch: (...args: any[]) => utils .client.ai.mutate(JSON.parse(args[1].body), { signal: args[1].signal,context: {skipBatch: true,}}),});return (<>{chat.messages.map((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.tsxtsx
import { useChat } from '@ai-sdk/re act';import { trpc }from '../utils/trpc'; import React from 'react';export function Greeting() {const utils = trpc.useUtils();constchat = useChat({ fetch: (...args: any[]) => utils.client.ai. mutate(JSON.parse(args[1].body), { signal: args[1].signal,context : {skipBatch: true,}}),});return (<>{chat.messages.map((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.