trpc-rest

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

OptionTypeRequiredDescription
routerAnyRouterYesYour tRPC router
endpointstringYesURL prefix to strip before matching (e.g. /api)
reqRequestYesThe incoming Fetch Request
createContext(opts) => Promise<Context>YesContext factory, receives { req, info }
responseMeta(opts) => { status?, headers? }NoCustomize response status and headers
onError(opts) => voidNoError callback for logging/monitoring

How routing works

  1. The handler builds a route table from your router's procedures (cached per router + endpoint)
  2. Each procedure with openapi metadata becomes a route: method + path → procedure
  3. Incoming requests are matched against the route table using regex
  4. Path params are extracted, query params are parsed and coerced, body is parsed
  5. The procedure is called via createCaller — your full middleware chain runs

Body parsing

The handler supports two content types:

  • application/json — parsed as JSON
  • application/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.

On this page