tRPC + OpenAPI Integration
Overview
Generate REST endpoints and OpenAPI documentation from your tRPC routers. Get the best of both worlds: type-safe internal API with tRPC, REST/Swagger for external consumers.
Package: trpc-to-openapi (active fork of archived trpc-openapi )
Requirements: tRPC v11+, Zod
Key Benefit: Single source of truth — define once in tRPC, expose as both RPC and REST.
When to Use This Skill
✅ Use tRPC + OpenAPI when:
-
Internal apps use tRPC, but need REST for third parties
-
Need Swagger/OpenAPI documentation
-
Mobile apps (non-React Native) need REST endpoints
-
Microservices with mixed languages need interop
-
Public API requires REST standard
❌ Skip OpenAPI layer when:
-
All clients are TypeScript (pure tRPC is better)
-
Internal-only APIs
-
No documentation requirements
Quick Start
Installation
NOTE: trpc-openapi is ARCHIVED, use active fork
npm install trpc-to-openapi swagger-ui-express
npm install -D @types/swagger-ui-express
Setup tRPC with OpenAPI Meta
// src/server/trpc.ts import { initTRPC } from '@trpc/server'; import { OpenApiMeta } from 'trpc-to-openapi';
const t = initTRPC .context<Context>() .meta<OpenApiMeta>() // ← Enable OpenAPI metadata .create();
export const router = t.router; export const publicProcedure = t.procedure;
Define Procedures with OpenAPI Metadata
// src/server/routers/user.ts import { z } from 'zod'; import { router, publicProcedure, protectedProcedure } from '../trpc';
const UserSchema = z.object({ id: z.string(), email: z.string().email(), name: z.string(), });
export const userRouter = router({ // GET /api/users/{id} getById: publicProcedure .meta({ openapi: { method: 'GET', path: '/users/{id}', tags: ['Users'], summary: 'Get user by ID', description: 'Retrieves a single user by their unique identifier', }, }) .input(z.object({ id: z.string() })) .output(UserSchema) .query(async ({ input, ctx }) => { return ctx.db.user.findUniqueOrThrow({ where: { id: input.id } }); }),
// GET /api/users?limit=10&cursor=xxx list: publicProcedure .meta({ openapi: { method: 'GET', path: '/users', tags: ['Users'], summary: 'List users', }, }) .input(z.object({ limit: z.number().min(1).max(100).default(10), cursor: z.string().optional(), })) .output(z.object({ items: z.array(UserSchema), nextCursor: z.string().optional(), })) .query(async ({ input, ctx }) => { // pagination logic }),
// POST /api/users (protected) create: protectedProcedure .meta({ openapi: { method: 'POST', path: '/users', tags: ['Users'], summary: 'Create user', protect: true, // ← Marks as requiring auth in docs }, }) .input(z.object({ email: z.string().email(), name: z.string().min(2), })) .output(UserSchema) .mutation(async ({ input, ctx }) => { return ctx.db.user.create({ data: input }); }),
// PUT /api/users/{id} update: protectedProcedure .meta({ openapi: { method: 'PUT', path: '/users/{id}', tags: ['Users'], protect: true, }, }) .input(z.object({ id: z.string(), name: z.string().optional(), email: z.string().email().optional(), })) .output(UserSchema) .mutation(async ({ input, ctx }) => { const { id, ...data } = input; return ctx.db.user.update({ where: { id }, data }); }),
// DELETE /api/users/{id} delete: protectedProcedure .meta({ openapi: { method: 'DELETE', path: '/users/{id}', tags: ['Users'], protect: true, }, }) .input(z.object({ id: z.string() })) .output(z.object({ success: z.boolean() })) .mutation(async ({ input, ctx }) => { await ctx.db.user.delete({ where: { id: input.id } }); return { success: true }; }), });
Generate OpenAPI Document
// src/server/openapi.ts import { generateOpenApiDocument } from 'trpc-to-openapi'; import { appRouter } from './routers/_app';
export const openApiDocument = generateOpenApiDocument(appRouter, { title: 'My API', version: '1.0.0', baseUrl: process.env.API_URL || 'http://localhost:3000/api', description: 'REST API documentation', securitySchemes: { bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT', }, }, });
Serve REST Endpoints + Swagger UI
// src/server/index.ts import express from 'express'; import cors from 'cors'; import swaggerUi from 'swagger-ui-express'; import { createExpressMiddleware } from '@trpc/server/adapters/express'; import { createOpenApiExpressMiddleware } from 'trpc-to-openapi'; import { appRouter } from './routers/_app'; import { createContext } from './context'; import { openApiDocument } from './openapi';
const app = express(); app.use(cors()); app.use(express.json());
// tRPC endpoint (for TypeScript clients) app.use('/trpc', createExpressMiddleware({ router: appRouter, createContext, }));
// REST/OpenAPI endpoints (for external clients) app.use('/api', createOpenApiExpressMiddleware({ router: appRouter, createContext, }));
// Swagger UI documentation app.use('/docs', swaggerUi.serve, swaggerUi.setup(openApiDocument));
// OpenAPI JSON spec app.get('/openapi.json', (req, res) => { res.json(openApiDocument); });
app.listen(3000, () => { console.log('Server: http://localhost:3000'); console.log('tRPC: http://localhost:3000/trpc'); console.log('REST: http://localhost:3000/api'); console.log('Docs: http://localhost:3000/docs'); });
URL Parameter Mapping
// Path parameters use {param} syntax .meta({ openapi: { method: 'GET', path: '/users/{id}/posts/{postId}', }, }) .input(z.object({ id: z.string(), // ← Maps to {id} postId: z.string(), // ← Maps to {postId} }))
// Query parameters are auto-mapped for GET .meta({ openapi: { method: 'GET', path: '/users', }, }) .input(z.object({ limit: z.number(), // ← ?limit=10 search: z.string(), // ← &search=foo }))
When to Expose OpenAPI
Scenario Recommendation
Internal TypeScript clients Pure tRPC
Third-party integrations tRPC + OpenAPI
Public API documentation tRPC + OpenAPI
Mobile apps (non-React Native) tRPC + OpenAPI
Microservices (mixed languages) OpenAPI
Rules
Do ✅
-
Add .output() schema for OpenAPI response types
-
Use descriptive summary and description
-
Group related endpoints with tags
-
Mark protected routes with protect: true
-
Use path parameters for resource identifiers
Avoid ❌
-
Exposing all procedures (only add meta to public ones)
-
Missing output schemas (breaks OpenAPI generation)
-
Inconsistent path naming conventions
-
Skipping authentication markers
OpenAPI Metadata Reference
.meta({ openapi: { method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE', path: '/resource/{id}', tags: ['Category'], summary: 'Short description', description: 'Detailed description', protect: boolean, // Requires auth deprecated: boolean, // Mark as deprecated requestHeaders: z.object(), // Custom headers responseHeaders: z.object(), contentTypes: ['application/json'], }, })
Troubleshooting
"OpenAPI generation fails": → Ensure all procedures with meta have .output() → Check Zod schemas are serializable → Verify path parameters match input schema
"REST endpoint returns 404": → Check path matches exactly (case-sensitive) → Verify HTTP method matches → Ensure createOpenApiExpressMiddleware is mounted
"Auth not working on REST": → Check Authorization header format → Verify createContext extracts token → Match auth middleware with tRPC setup
"Swagger UI empty": → Check openApiDocument is generated → Verify /openapi.json returns valid spec → Check console for generation errors
File Structure
src/server/ ├── trpc.ts # tRPC with OpenApiMeta ├── openapi.ts # OpenAPI document generation ├── context.ts # Shared context ├── index.ts # Express server └── routers/ ├── _app.ts # Root router └── user.ts # Procedures with openapi meta
References
-
https://github.com/mcampa/trpc-to-openapi — Active fork documentation
-
https://swagger.io/specification/ — OpenAPI spec
-
https://swagger.io/tools/swagger-ui/ — Swagger UI