REST Handler
Handle incoming HTTP requests as tRPC OpenAPI calls.
createOpenApiFetchHandler routes incoming HTTP requests to tRPC procedures based on OpenAPI metadata. It runs the full middleware chain via createCaller.
Basic usage
import { createOpenApiFetchHandler } from 'trpc-rest';
const response = await createOpenApiFetchHandler({
router: appRouter,
endpoint: '/api',
req: request,
createContext: async ({ req }) => ({ userId: getUserId(req) }),
});Options
| Option | Type | Required | Description |
|---|---|---|---|
router | AnyRouter | Yes | Your tRPC router |
endpoint | string | Yes | URL prefix to strip before matching (e.g. /api) |
req | Request | Yes | The incoming Fetch Request |
createContext | (opts) => Promise<Context> | Yes | Context factory, receives { req, info } |
responseMeta | (opts) => { status?, headers? } | No | Customize response status and headers |
onError | (opts) => void | No | Error callback for logging/monitoring |
How routing works
- The handler builds a route table from your router's procedures (cached per router + endpoint)
- Each procedure with
openapimetadata becomes a route:method + path → procedure - Incoming requests are matched against the route table using regex
- Path params are extracted, query params are parsed and coerced, body is parsed
- The procedure is called via
createCaller— your full middleware chain runs
Body parsing
The handler supports two content types:
application/json— parsed as JSONapplication/x-www-form-urlencoded— parsed as key-value pairs
Any other content type returns a 415 Unsupported Media Type error.
Smart body detection: If a POST/PUT/PATCH endpoint has all input fields covered by path params, the handler skips body parsing entirely. No content-type header required.
// This endpoint has only path params — no body needed
t.procedure
.meta({ openapi: { method: 'POST', path: '/items/{id}/confirm' } })
.input(z.object({ id: z.string() }))
.mutation(({ input }) => confirm(input.id));HEAD requests
The handler responds to HEAD requests for health checks and warmup probes:
- If any route matches the path (regardless of method):
200 - If no route matches:
404
Response format
Success:
{ "id": "user-1", "name": "Alice" }The response body is the raw procedure output — no wrapper object.
Error:
{
"message": "Input validation failed",
"code": "BAD_REQUEST",
"issues": [{ "message": "Required" }]
}Custom response headers
Use responseMeta to add headers or override the status code:
createOpenApiFetchHandler({
// ...
responseMeta: ({ type, errors }) => {
if (errors.length > 0) {
return { headers: { 'X-Error': 'true' } };
}
return {
status: 200,
headers: { 'Cache-Control': 'public, max-age=60' },
};
},
});Error logging
Use onError to log errors to your monitoring service:
createOpenApiFetchHandler({
// ...
onError: ({ error, path, input, req }) => {
console.error(`[${error.code}] ${path}:`, error.message);
sentry.captureException(error);
},
});Route caching
The route table is built once per router + endpoint combination and cached in a WeakMap. When the router is garbage collected, the cache entry is automatically cleaned up.