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.

allowed_tools: Read, Edit, Write, Bash (*)

$ 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

ScenarioRecommendation
Internal TypeScript clientsPure tRPC
Third-party integrationstRPC + OpenAPI
Public API documentationtRPC + 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