backend-trpc-openapi
Generate OpenAPI/REST endpoints from tRPC routers. Use when you have a tRPC API but need to expose REST endpoints for third-party integrations, mobile apps, or public API documentation. Provides automatic Swagger UI and OpenAPI spec generation. Choose this when you want type-safe internal APIs (tRPC) with REST fallback for external consumers.
$ Instalar
git clone https://github.com/petbrains/mvp-builder /tmp/mvp-builder && cp -r /tmp/mvp-builder/.claude/skills/backend-trpc-openapi ~/.claude/skills/mvp-builder// tip: Run this command in your terminal to install the skill
name: backend-trpc-openapi description: Generate OpenAPI/REST endpoints from tRPC routers. Use when you have a tRPC API but need to expose REST endpoints for third-party integrations, mobile apps, or public API documentation. Provides automatic Swagger UI and OpenAPI spec generation. Choose this when you want type-safe internal APIs (tRPC) with REST fallback for external consumers. allowed-tools: Read, Edit, Write, Bash (*)
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
summaryanddescription - 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
Repository
