skills / bobmatnyc / claude-mpm-skills / trpc-type-safety 
trpc-type-safety 
$ npx skills add https://github.com/bobmatnyc/claude-mpm-skills --skill trpc-type-safety SKILL.md 
tRPC - End-to-End Type Safety 
progressive_disclosure:
entry_point: summary
sections:
- id: summary
title: "tRPC Overview"
tokens: 70
next: [when_to_use, quick_start]
- id: when_to_use
title: "When to Use tRPC"
tokens: 150
next: [quick_start, core_concepts]
- id: quick_start
title: "Quick Start"
tokens: 300
next: [core_concepts, router_definition]
- id: core_concepts
title: "Core Concepts"
tokens: 400
next: [router_definition, procedures]
- id: router_definition
title: "Router Definition"
tokens: 350
next: [procedures, context]
- id: procedures
title: "Procedures (Query & Mutation)"
tokens: 400
next: [input_validation, context]
- id: input_validation
title: "Input Validation with Zod"
tokens: 350
next: [context, middleware]
- id: context
title: "Context Management"
tokens: 400
next: [middleware, error_handling]
- id: middleware
title: "Middleware"
tokens: 400
next: [error_handling, client_setup]
- id: error_handling
title: "Error Handling"
tokens: 350
next: [client_setup, react_integration]
- id: client_setup
title: "Client Setup"
tokens: 400
next: [react_integration, nextjs_integration]
- id: react_integration
title: "React Query Integration"
tokens: 450
next: [nextjs_integration, subscriptions]
- id: nextjs_integration
title: "Next.js App Router Integration"
tokens: 500
next: [subscriptions, file_uploads]
- id: subscriptions
title: "Real-time Subscriptions"
tokens: 400
next: [file_uploads, batching]
- id: file_uploads
title: "File Uploads"
tokens: 300
next: [batching, typescript_inference]
- id: batching
title: "Batch Requests & Data Loaders"
tokens: 350
next: [typescript_inference, testing]
- id: typescript_inference
title: "TypeScript Inference Patterns"
tokens: 300
next: [testing, production_patterns]
- id: testing
title: "Testing Strategies"
tokens: 400
next: [production_patterns, comparison]
- id: production_patterns
title: "Production Patterns"
tokens: 450
next: [comparison, migration]
- id: comparison
title: "Comparison with REST & GraphQL"
tokens: 250
next: [migration, best_practices]
- id: migration
title: "Migration from REST"
tokens: 300
next: [best_practices]
- id: best_practices
title: "Best Practices & Performance"
tokens: 400 
Summary 
tRPC enables end-to-end type safety between TypeScript clients and servers without code generation. Define your API once, get automatic type inference everywhere. 
Key Benefits : Zero codegen, TypeScript inference, React Query integration, minimal boilerplate. 
When to Use tRPC 
✅ Perfect For : 
Full-stack TypeScript applications (Next.js, T3 stack) 
Projects where client and server share TypeScript codebase 
Teams wanting REST-like simplicity with GraphQL-like type safety 
Apps using React Query for data fetching 
Internal APIs where you control both client and server 
❌ Avoid When : 
Public APIs consumed by non-TypeScript clients 
Microservices in different languages 
Mobile apps using Swift/Kotlin (use REST/GraphQL instead) 
Need API documentation for external developers (OpenAPI better) 
When to Choose : 
tRPC : Full-stack TypeScript, monorepo, internal tools 
REST : Public APIs, language-agnostic, broad compatibility 
GraphQL : Complex data graphs, multiple clients, flexible queries 
Quick Start 
Installation 

# Server dependencies npm install @trpc/server zod # React/Next.js client dependencies npm install @trpc/client @trpc/react-query @tanstack/react-query 
Define Router (Server) 

// server/trpc.ts import { initTRPC } from '@trpc/server' ; import { z } from 'zod' ; const t = initTRPC . create ( ) ; export const appRouter = t . router ( { hello : t . procedure . input ( z . object ( { name : z . string ( ) } ) ) . query ( ( { input } ) => { return { greeting : ` Hello ${ input . name } ` } ; } ) , createPost : t . procedure . input ( z . object ( { title : z . string ( ) , content : z . string ( ) } ) ) . mutation ( async ( { input } ) => { // Save to database return { id : 1 , ... input } ; } ) , } ) ; export type AppRouter = typeof appRouter ; 
Use in Client (React) 

// client/trpc.ts import { createTRPCReact } from '@trpc/react-query' ; import type { AppRouter } from '../server/trpc' ; export const trpc = createTRPCReact < AppRouter > ( ) ; // Component function MyComponent ( ) { const { data } = trpc . hello . useQuery ( { name : 'World' } ) ; const createPost = trpc . createPost . useMutation ( ) ; return < div > { data ?. greeting } < / div > ; // Fully typed! } 
Next : Learn core concepts or dive into router definition. 
Core Concepts 
The tRPC Philosophy 
tRPC provides type-safe remote procedure calls by sharing TypeScript types between client and server. No code generation—just TypeScript's inference. 
Key Components 
Router : Collection of procedures (API endpoints) 
Procedure : Single API operation (query or mutation) 
Context : Request-scoped data (user, database, etc.) 
Middleware : Intercept/modify requests (auth, logging) 
Input/Output : Validated with Zod schemas 
Type Flow 

// Server defines types const router = t . router ( { getUser : t . procedure . input ( z . string ( ) ) . query ( ( { input } ) => ( { id : input , name : 'Alice' } ) ) , } ) ; // Client gets automatic types const user = await trpc . getUser . query ( '123' ) ; // user is typed as { id: string, name: string } 
Architecture Pattern 

┌─────────────┐     Type-safe     ┌──────────────┐ │   Client    │ ←────────────────→ │    Server    │ │ (React)     │   No codegen!      │   (Node.js)  │ └─────────────┘                    └──────────────┘ ↓                                    ↓ React Query                          tRPC Router (caching)                            (procedures) 
Advantages : 
Changes propagate instantly (no build step) 
Rename refactoring works across client/server 
Impossible to call wrong types 
Auto-complete for all API methods 
Router Definition 
Basic Router Structure 

import { initTRPC } from '@trpc/server' ; const t = initTRPC . create ( ) ; export const appRouter = t . router ( { // Procedures go here } ) ; export type AppRouter = typeof appRouter ; 
Nested Routers (Namespacing) 

const userRouter = t . router ( { getById : t . procedure . input ( z . string ( ) ) . query ( ( { input } ) => getUser ( input ) ) , create : t . procedure . input ( z . object ( { name : z . string ( ) , email : z . string ( ) } ) ) . mutation ( ( { input } ) => createUser ( input ) ) , } ) ; const postRouter = t . router ( { list : t . procedure . query ( ( ) => getPosts ( ) ) , create : t . procedure . input ( z . object ( { title : z . string ( ) } ) ) . mutation ( ( { input } ) => createPost ( input ) ) , } ) ; export const appRouter = t . router ( { user : userRouter , post : postRouter , } ) ; // Client usage: // trpc.user.getById.useQuery('123') // trpc.post.list.useQuery() 
Router Merging 

import { adminRouter } from './admin' ; import { publicRouter } from './public' ; export const appRouter = t . mergeRouters ( publicRouter , adminRouter ) ; 
Router Organization Best Practices 

server/ ├── trpc.ts           # tRPC instance, context, middleware ├── routers/ │   ├── user.ts       # User-related procedures │   ├── post.ts       # Post-related procedures │   └── index.ts      # Combine all routers └── index.ts          # Export AppRouter type 
Procedures (Query & Mutation) 
Query Procedures (Read Operations) 

const router = t . router ( { // Simple query getUser : t . procedure . input ( z . string ( ) ) . query ( ( { input } ) => { return db . user . findUnique ( { where : { id : input } } ) ; } ) , // Query with multiple inputs searchUsers : t . procedure . input ( z . object ( { query : z . string ( ) , limit : z . number ( ) . default ( 10 ) , } ) ) . query ( ( { input } ) => { return db . user . findMany ( { where : { name : { contains : input . query } } , take : input . limit , } ) ; } ) , } ) ; 
Mutation Procedures (Write Operations) 

const router = t . router ( { createUser : t . procedure . input ( z . object ( { name : z . string ( ) . min ( 3 ) , email : z . string ( ) . email ( ) , } ) ) . mutation ( async ( { input } ) => { return await db . user . create ( { data : input } ) ; } ) , updateUser : t . procedure . input ( z . object ( { id : z . string ( ) , data : z . object ( { name : z . string ( ) . optional ( ) , email : z . string ( ) . email ( ) . optional ( ) , } ) , } ) ) . mutation ( async ( { input } ) => { return await db . user . update ( { where : { id : input . id } , data : input . data , } ) ; } ) , } ) ; 
Query vs Mutation Aspect Query Mutation Purpose Read data Modify data HTTP Method GET POST Caching Cached by React Query Not cached Idempotent Yes No Side Effects None Database writes, emails, etc. 
Output Typing 

const router = t . router ( { getUser : t . procedure . input ( z . string ( ) ) . output ( z . object ( { id : z . string ( ) , name : z . string ( ) } ) ) // Optional . query ( ( { input } ) => { return { id : input , name : 'Alice' } ; } ) , } ) ; 
Note : Output validation adds runtime overhead—use for critical data only. 
Input Validation with Zod 
Why Zod? 
tRPC uses Zod for runtime type validation and TypeScript inference. Zod schemas provide: 
Runtime validation (prevent invalid data) 
TypeScript types (auto-inferred from schema) 
Transformation (parse, coerce, default values) 
Basic Validation 

import { z } from 'zod' ; const router = t . router ( { createPost : t . procedure . input ( z . object ( { title : z . string ( ) . min ( 5 ) . max ( 100 ) , content : z . string ( ) , published : z . boolean ( ) . default ( false ) , tags : z . array ( z . string ( ) ) . optional ( ) , } ) ) . mutation ( ( { input } ) => { // input is fully typed and validated return createPost ( input ) ; } ) , } ) ; 
Advanced Validation 

const createUserInput = z . object ( { email : z . string ( ) . email ( ) , password : z . string ( ) . min ( 8 ) , age : z . number ( ) . int ( ) . min ( 18 ) , role : z . enum ( [ 'user' , 'admin' ] ) , metadata : z . record ( z . string ( ) , z . unknown ( ) ) . optional ( ) , } ) ; const router = t . router ( { createUser : t . procedure . input ( createUserInput ) . mutation ( ( { input } ) => { // All validation passed return saveUser ( input ) ; } ) , } ) ; 
Transformations 

const router = t . router ( { getUser : t . procedure . input ( z . object ( { id : z . string ( ) . transform ( ( id ) => parseInt ( id , 10 ) ) , } ) ) . query ( ( { input } ) => { // input.id is now a number return db . user . findUnique ( { where : { id : input . id } } ) ; } ) , } ) ; 
Reusable Schemas 

// schemas/user.ts export const CreateUserSchema = z . object ( { name : z . string ( ) , email : z . string ( ) . email ( ) , } ) ; export const UpdateUserSchema = CreateUserSchema . partial ( ) . extend ( { id : z . string ( ) , } ) ; // routers/user.ts const router = t . router ( { create : t . procedure . input ( CreateUserSchema ) . mutation ( /*...*/ ) , update : t . procedure . input ( UpdateUserSchema ) . mutation ( /*...*/ ) , } ) ; 
Context Management 
What is Context? 
Context provides request-scoped data to all procedures—authentication, database connections, logging, etc. 
Creating Context 

import { inferAsyncReturnType } from '@trpc/server' ; import { CreateNextContextOptions } from '@trpc/server/adapters/next' ; export async function createContext ( opts : CreateNextContextOptions ) { const session = await getSession ( opts . req ) ; return { session , db : prisma , req : opts . req , res : opts . res , } ; } export type Context = inferAsyncReturnType < typeof createContext > ; const t = initTRPC . context < Context > ( ) . create ( ) ; 
Using Context in Procedures 

const router = t . router ( { getMe : t . procedure . query ( ( { ctx } ) => { if ( ! ctx . session ?. user ) { throw new TRPCError ( { code : 'UNAUTHORIZED' } ) ; } return ctx . db . user . findUnique ( { where : { id : ctx . session . user . id } , } ) ; } ) , createPost : t . procedure . input ( z . object ( { title : z . string ( ) } ) ) . mutation ( async ( { ctx , input } ) => { return ctx . db . post . create ( { data : { title : input . title , authorId : ctx . session . user . id , } , } ) ; } ) , } ) ; 
Context Best Practices 

// ✅ Good: Lazy database connection export async function createContext ( opts : CreateNextContextOptions ) { return { getDB : ( ) => prisma , // Lazy session : await getSession ( opts . req ) , } ; } // ❌ Bad: Heavy computation in context export async function createContext ( opts : CreateNextContextOptions ) { const allUsers = await prisma . user . findMany ( ) ; // Too expensive! return { allUsers } ; } 
Middleware 
What is Middleware? 
Middleware intercepts procedure calls to add cross-cutting concerns: logging, timing, authentication, rate limiting. 
Basic Middleware 

const loggerMiddleware = t . middleware ( async ( { path , type , next } ) => { const start = Date . now ( ) ; console . log ( ` → ${ type } ${ path } ` ) ; const result = await next ( ) ; const duration = Date . now ( ) - start ; console . log ( ` ✓ ${ type } ${ path } - ${ duration } ms ` ) ; return result ; } ) ; const loggedProcedure = t . procedure . use ( loggerMiddleware ) ; 
Authentication Middleware 

const isAuthed = t . middleware ( ( { ctx , next } ) => { if ( ! ctx . session ?. user ) { throw new TRPCError ( { code : 'UNAUTHORIZED' } ) ; } return next ( { ctx : { ... ctx , user : ctx . session . user , // Narrow type } , } ) ; } ) ; // Protected procedure builder const protectedProcedure = t . procedure . use ( isAuthed ) ; const router = t . router ( { // Public getPublicPosts : t . procedure . query ( ( ) => getPosts ( ) ) , // Protected - requires authentication getMyPosts : protectedProcedure . query ( ( { ctx } ) => { // ctx.user is guaranteed to exist return getPostsByUser ( ctx . user . id ) ; } ) , } ) ; 
Chaining Middleware 

const timingMiddleware = t . middleware ( async ( { next } ) => { const start = performance . now ( ) ; const result = await next ( ) ; console . log ( ` Execution time: ${ performance . now ( ) - start } ms ` ) ; return result ; } ) ; const rateLimitMiddleware = t . middleware ( async ( { ctx , next } ) => { await checkRateLimit ( ctx . session ?. user ?. id ) ; return next ( ) ; } ) ; const protectedProcedure = t . procedure . use ( timingMiddleware ) . use ( rateLimitMiddleware ) . use ( isAuthed ) ; 
Context Transformation 

const enrichContextMiddleware = t . middleware ( async ( { ctx , next } ) => { const user = ctx . session ?. user ? await ctx . db . user . findUnique ( { where : { id : ctx . session . user . id } } ) : null ; return next ( { ctx : { ... ctx , user , // Full user object } , } ) ; } ) ; 
Error Handling 
TRPCError 

import { TRPCError } from '@trpc/server' ; const router = t . router ( { getUser : t . procedure . input ( z . string ( ) ) . query ( async ( { input } ) => { const user = await db . user . findUnique ( { where : { id : input } } ) ; if ( ! user ) { throw new TRPCError ( { code : 'NOT_FOUND' , message : ` User ${ input } not found ` , } ) ; } return user ; } ) , } ) ; 
Error Codes Code HTTP Status Use Case 
BAD_REQUEST 400 Invalid input 
UNAUTHORIZED 401 Not authenticated 
FORBIDDEN 403 Not authorized 
NOT_FOUND 404 Resource not found 
TIMEOUT 408 Request timeout 
CONFLICT 409 Resource conflict 
PRECONDITION_FAILED 412 Precondition failed 
PAYLOAD_TOO_LARGE 413 Request too large 
TOO_MANY_REQUESTS 429 Rate limit exceeded 
CLIENT_CLOSED_REQUEST 499 Client closed connection 
INTERNAL_SERVER_ERROR 500 Server error 
Custom Error Handling 

const router = t . router ( { deleteUser : t . procedure . input ( z . string ( ) ) . mutation ( async ( { input , ctx } ) => { try { return await ctx . db . user . delete ( { where : { id : input } } ) ; } catch ( error ) { if ( error . code === 'P2025' ) { // Prisma not found throw new TRPCError ( { code : 'NOT_FOUND' , message : 'User not found' , cause : error , } ) ; } throw new TRPCError ( { code : 'INTERNAL_SERVER_ERROR' , message : 'Failed to delete user' , cause : error , } ) ; } } ) , } ) ; 
Error Formatting 

const t = initTRPC . context < Context > ( ) . create ( { errorFormatter ( { shape , error } ) { return { ... shape , data : { ... shape . data , zodError : error . code === 'BAD_REQUEST' && error . cause instanceof ZodError ? error . cause . flatten ( ) : null , } , } ; } , } ) ; 
Client-Side Error Handling 

function MyComponent ( ) { const mutation = trpc . createUser . useMutation ( { onError : ( error ) => { if ( error . data ?. code === 'UNAUTHORIZED' ) { router . push ( '/login' ) ; } else { toast . error ( error . message ) ; } } , } ) ; } 
Client Setup 
Vanilla Client 

import { createTRPCProxyClient , httpBatchLink } from '@trpc/client' ; import type { AppRouter } from './server' ; const client = createTRPCProxyClient < AppRouter > ( { links : [ httpBatchLink ( { url : 'http://localhost:3000/api/trpc' , } ) , ] , } ) ; // Usage const user = await client . user . getById . query ( '123' ) ; const newPost = await client . post . create . mutate ( { title : 'Hello' } ) ; 
React Client Setup 

// utils/trpc.ts import { createTRPCReact } from '@trpc/react-query' ; import type { AppRouter } from '../server/routers' ; export const trpc = createTRPCReact < AppRouter > ( ) ; // _app.tsx import { QueryClient , QueryClientProvider } from '@tanstack/react-query' ; import { httpBatchLink } from '@trpc/client' ; import { useState } from 'react' ; import { trpc } from '../utils/trpc' ; export default function App ( { Component , pageProps } : AppProps ) { const [ queryClient ] = useState ( ( ) => new QueryClient ( ) ) ; const [ trpcClient ] = useState ( ( ) => trpc . createClient ( { links : [ httpBatchLink ( { url : 'http://localhost:3000/api/trpc' , } ) , ] , } ) ) ; return ( < trpc . Provider client = { trpcClient } queryClient = { queryClient } > < QueryClientProvider client = { queryClient } > < Component { ... pageProps } / > < / QueryClientProvider > < / trpc . Provider > ) ; } 
Next.js API Route 

// pages/api/trpc/[trpc].ts import { createNextApiHandler } from '@trpc/server/adapters/next' ; import { appRouter } from '../../../server/routers' ; import { createContext } from '../../../server/context' ; export default createNextApiHandler ( { router : appRouter , createContext , } ) ; 
Headers & Authentication 

const trpcClient = trpc . createClient ( { links : [ httpBatchLink ( { url : 'http://localhost:3000/api/trpc' , headers : async ( ) => { const token = await getAuthToken ( ) ; return { authorization : token ? ` Bearer ${ token } ` : undefined , } ; } , } ) , ] , } ) ; 
React Query Integration 
useQuery Hook 

function UserProfile ( { userId } : { userId : string } ) { const { data , isLoading , error } = trpc . user . getById . useQuery ( userId ) ; if ( isLoading ) return < div > Loading ... < / div > ; if ( error ) return < div > Error : { error . message } < / div > ; return < div > { data . name } < / div > ; } 
Query Options 

const { data } = trpc . posts . list . useQuery ( undefined , { refetchOnWindowFocus : false , staleTime : 5 * 60 * 1000 , // 5 minutes cacheTime : 10 * 60 * 1000 , // 10 minutes retry : 3 , onSuccess : ( data ) => console . log ( 'Fetched' , data . length , 'posts' ) , } ) ; 
useMutation Hook 

function CreatePostForm ( ) { const utils = trpc . useContext ( ) ; const createPost = trpc . post . create . useMutation ( { onSuccess : ( ) => { // Invalidate and refetch utils . post . list . invalidate ( ) ; } , } ) ; const handleSubmit = ( data : { title : string } ) => { createPost . mutate ( data ) ; } ; return ( < form onSubmit = { handleSubmit } > < input name = "title" / > < button disabled = { createPost . isLoading } > { createPost . isLoading ? 'Creating...' : 'Create' } < / button > { createPost . error && < p > { createPost . error . message } < / p > } < / form > ) ; } 
Optimistic Updates 

const createPost = trpc . post . create . useMutation ( { onMutate : async ( newPost ) => { // Cancel outgoing refetches await utils . post . list . cancel ( ) ; // Snapshot previous value const previousPosts = utils . post . list . getData ( ) ; // Optimistically update utils . post . list . setData ( undefined , ( old ) => [ ... ( old ?? [ ] ) , { id : 'temp' , ... newPost } , ] ) ; return { previousPosts } ; } , onError : ( err , newPost , context ) => { // Rollback on error utils . post . list . setData ( undefined , context ?. previousPosts ) ; } , onSettled : ( ) => { // Refetch after success or error utils . post . list . invalidate ( ) ; } , } ) ; 
Infinite Queries 

// Server const router = t . router ( { posts : t . procedure . input ( z . object ( { cursor : z . number ( ) . optional ( ) , limit : z . number ( ) . default ( 10 ) , } ) ) . query ( ( { input } ) => { const posts = getPosts ( input . cursor , input . limit ) ; return { posts , nextCursor : posts . length === input . limit ? input . cursor + input . limit : undefined , } ; } ) , } ) ; // Client function PostList ( ) { const { data , fetchNextPage , hasNextPage , isFetchingNextPage , } = trpc . posts . useInfiniteQuery ( { limit : 10 } , { getNextPageParam : ( lastPage ) => lastPage . nextCursor , } ) ; return ( < div > { data ?. pages . map ( ( page ) => page . posts . map ( ( post ) => < PostCard key = { post . id } post = { post } / > ) ) } { hasNextPage && ( < button onClick = { ( ) => fetchNextPage ( ) } disabled = { isFetchingNextPage } > Load More < / button > ) } < / div > ) ; } 
Next.js App Router Integration 
Server Components 

// app/users/page.tsx (Server Component) import { createCaller } from '../server/routers' ; import { createContext } from '../server/context' ; export default async function UsersPage ( ) { const ctx = await createContext ( { req : null , res : null } ) ; const caller = createCaller ( ctx ) ; const users = await caller . user . list ( ) ; return ( < div > { users . map ( ( user ) => ( < div key = { user . id } > { user . name } < / div > ) ) } < / div > ) ; } 
Server Actions 

// app/actions.ts 'use server' ; import { createCaller } from '../server/routers' ; import { createContext } from '../server/context' ; export async function createPost ( formData : FormData ) { const ctx = await createContext ( { req : null , res : null } ) ; const caller = createCaller ( ctx ) ; return caller . post . create ( { title : formData . get ( 'title' ) as string , content : formData . get ( 'content' ) as string , } ) ; } 
App Router Provider 

// app/providers.tsx 'use client' ; import { QueryClient , QueryClientProvider } from '@tanstack/react-query' ; import { httpBatchLink } from '@trpc/client' ; import { useState } from 'react' ; import { trpc } from './trpc' ; export function Providers ( { children } : { children : React . ReactNode } ) { const [ queryClient ] = useState ( ( ) => new QueryClient ( ) ) ; const [ trpcClient ] = useState ( ( ) => trpc . createClient ( { links : [ httpBatchLink ( { url : '/api/trpc' , } ) , ] , } ) ) ; return ( < trpc . Provider client = { trpcClient } queryClient = { queryClient } > < QueryClientProvider client = { queryClient } > { children } < / QueryClientProvider > < / trpc . Provider > ) ; } // app/layout.tsx import { Providers } from './providers' ; export default function RootLayout ( { children } ) { return ( < html > < body > < Providers > { children } < / Providers > < / body > < / html > ) ; } 
Client Components in App Router 

// app/posts/create-button.tsx 'use client' ; import { trpc } from '../trpc' ; export function CreatePostButton ( ) { const createPost = trpc . post . create . useMutation ( ) ; return ( < button onClick = { ( ) => createPost . mutate ( { title : 'New Post' } ) } > Create Post < / button > ) ; } 
API Route Handler (App Router) 

// app/api/trpc/[trpc]/route.ts import { fetchRequestHandler } from '@trpc/server/adapters/fetch' ; import { appRouter } from '../../../../server/routers' ; import { createContext } from '../../../../server/context' ; const handler = ( req : Request ) => fetchRequestHandler ( { endpoint : '/api/trpc' , req , router : appRouter , createContext , } ) ; export { handler as GET , handler as POST } ; 
Real-time Subscriptions 
WebSocket Setup (Server) 

import { applyWSSHandler } from '@trpc/server/adapters/ws' ; import ws from 'ws' ; const wss = new ws . Server ( { port : 3001 } ) ; applyWSSHandler ( { wss , router : appRouter , createContext , } ) ; console . log ( 'WebSocket server listening on port 3001' ) ; 
Subscription Procedure 

import { observable } from '@trpc/server/observable' ; import { EventEmitter } from 'events' ; const ee = new EventEmitter ( ) ; const router = t . router ( { onPostAdd : t . procedure . subscription ( ( ) => { return observable < Post > ( ( emit ) => { const onAdd = ( data : Post ) => emit . next ( data ) ; ee . on ( 'add' , onAdd ) ; return ( ) => { ee . off ( 'add' , onAdd ) ; } ; } ) ; } ) , createPost : t . procedure . input ( z . object ( { title : z . string ( ) } ) ) . mutation ( ( { input } ) => { const post = { id : Date . now ( ) . toString ( ) , ... input } ; ee . emit ( 'add' , post ) ; // Emit to subscribers return post ; } ) , } ) ; 
Client WebSocket Setup 

import { createWSClient , wsLink } from '@trpc/client' ; const wsClient = createWSClient ( { url : 'ws://localhost:3001' , } ) ; const trpcClient = trpc . createClient ( { links : [ wsLink ( { client : wsClient , } ) , ] , } ) ; 
useSubscription Hook 

function PostFeed ( ) { const [ posts , setPosts ] = useState < Post [ ] > ( [ ] ) ; trpc . onPostAdd . useSubscription ( undefined , { onData : ( post ) => { setPosts ( ( prev ) => [ post , ... prev ] ) ; } , onError : ( err ) => { console . error ( 'Subscription error:' , err ) ; } , } ) ; return ( < div > { posts . map ( ( post ) => ( < PostCard key = { post . id } post = { post } / > ) ) } < / div > ) ; } 
Subscription with Input 

// Server const router = t . router ( { onUserStatusChange : t . procedure . input ( z . string ( ) ) . subscription ( ( { input } ) => { return observable < UserStatus > ( ( emit ) => { const onChange = ( userId : string , status : UserStatus ) => { if ( userId === input ) { emit . next ( status ) ; } } ; ee . on ( 'statusChange' , onChange ) ; return ( ) => ee . off ( 'statusChange' , onChange ) ; } ) ; } ) , } ) ; // Client trpc . onUserStatusChange . useSubscription ( 'user-123' , { onData : ( status ) => console . log ( 'Status:' , status ) , } ) ; 
File Uploads 
Multipart Form Data (Server) 

// Next.js API route with file upload import { NextApiRequest , NextApiResponse } from 'next' ; import formidable from 'formidable' ; import fs from 'fs' ; export const config = { api : { bodyParser : false } , } ; export default async function handler ( req : NextApiRequest , res : NextApiResponse ) { const form = formidable ( { multiples : false } ) ; form . parse ( req , async ( err , fields , files ) => { if ( err ) return res . status ( 500 ) . json ( { error : 'Upload failed' } ) ; const file = files . file as formidable . File ; const buffer = fs . readFileSync ( file . filepath ) ; // Upload to S3, etc. const url = await uploadToS3 ( buffer , file . originalFilename ) ; res . json ( { url } ) ; } ) ; } 
Base64 Upload (tRPC) 

// For small files only (<1MB) const router = t . router ( { uploadAvatar : t . procedure . input ( z . object ( { fileName : z . string ( ) , fileData : z . string ( ) , // Base64 } ) ) . mutation ( async ( { input } ) => { const buffer = Buffer . from ( input . fileData , 'base64' ) ; const url = await uploadToS3 ( buffer , input . fileName ) ; return { url } ; } ) , } ) ; // Client const uploadAvatar = trpc . uploadAvatar . useMutation ( ) ; const handleFileChange = ( e : React . ChangeEvent < HTMLInputElement > ) => { const file = e . target . files ?. [ 0 ] ; if ( ! file ) return ; const reader = new FileReader ( ) ; reader . onload = ( ) => { const base64 = reader . result as string ; uploadAvatar . mutate ( { fileName : file . name , fileData : base64 . split ( ',' ) [ 1 ] , // Remove data:image/...;base64, } ) ; } ; reader . readAsDataURL ( file ) ; } ; 
Signed URL Pattern (Recommended) 

// Step 1: Get signed upload URL from tRPC const router = t . router ( { getUploadUrl : t . procedure . input ( z . object ( { fileName : z . string ( ) , fileType : z . string ( ) , } ) ) . mutation ( async ( { input } ) => { const signedUrl = await s3 . getSignedUrl ( 'putObject' , { Bucket : 'my-bucket' , Key : input . fileName , ContentType : input . fileType , Expires : 60 , // 1 minute } ) ; return { uploadUrl : signedUrl , fileUrl : ` https://cdn.example.com/ ${ input . fileName } ` } ; } ) , } ) ; // Step 2: Client uploads directly to S3 async function uploadFile ( file : File ) { // Get signed URL const { uploadUrl , fileUrl } = await trpc . getUploadUrl . mutate ( { fileName : file . name , fileType : file . type , } ) ; // Upload directly to S3 await fetch ( uploadUrl , { method : 'PUT' , body : file , headers : { 'Content-Type' : file . type } , } ) ; // Save file URL to database via tRPC await trpc . user . updateAvatar . mutate ( { url : fileUrl } ) ; } 
Batch Requests & Data Loaders 
Automatic Batching 

// Client configuration const trpcClient = trpc . createClient ( { links : [ httpBatchLink ( { url : 'http://localhost:3000/api/trpc' , maxBatchSize : 10 , // Batch up to 10 requests } ) , ] , } ) ; // Multiple calls made close together are batched into one HTTP request const user1 = trpc . user . getById . useQuery ( '1' ) ; const user2 = trpc . user . getById . useQuery ( '2' ) ; const user3 = trpc . user . getById . useQuery ( '3' ) ; // → Single HTTP request with 3 procedure calls 
DataLoader Pattern 

import DataLoader from 'dataloader' ; // Create DataLoader in context export async function createContext ( ) { const userLoader = new DataLoader ( async ( ids : readonly string [ ] ) => { const users = await db . user . findMany ( { where : { id : { in : [ ... ids ] } } , } ) ; // Return in same order as input return ids . map ( ( id ) => users . find ( ( u ) => u . id === id ) ) ; } ) ; return { userLoader } ; } // Use in procedures const router = t . router ( { getUser : t . procedure . input ( z . string ( ) ) . query ( ( { ctx , input } ) => { return ctx . userLoader . load ( input ) ; // Batched! } ) , getPosts : t . procedure . query ( async ( { ctx } ) => { const posts = await db . post . findMany ( { take : 10 } ) ; // N+1 problem solved—all authors fetched in one query const postsWithAuthors = await Promise . all ( posts . map ( async ( post ) => ( { ... post , author : await ctx . userLoader . load ( post . authorId ) , } ) ) ) ; return postsWithAuthors ; } ) , } ) ; 
Conditional Batching 

import { httpBatchLink , httpLink , splitLink } from '@trpc/client' ; const trpcClient = trpc . createClient ( { links : [ splitLink ( { // Batch queries, don't batch mutations condition : ( op ) => op . type === 'query' , true : httpBatchLink ( { url : '/api/trpc' } ) , false : httpLink ( { url : '/api/trpc' } ) , } ) , ] , } ) ; 
TypeScript Inference Patterns 
Inferring Types from Router 

import type { inferRouterInputs , inferRouterOutputs } from '@trpc/server' ; import type { AppRouter } from './server' ; // Input types type RouterInputs = inferRouterInputs < AppRouter > ; type CreateUserInput = RouterInputs [ 'user' ] [ 'create' ] ; // Output types type RouterOutputs = inferRouterOutputs < AppRouter > ; type User = RouterOutputs [ 'user' ] [ 'getById' ] ; // Use in components function UserCard ( { user } : { user : User } ) { return < div > { user . name } < / div > ; } 
Procedure Helpers 

import type { inferProcedureInput , inferProcedureOutput } from '@trpc/server' ; type CreatePostInput = inferProcedureInput < AppRouter [ 'post' ] [ 'create' ] > ; type Post = inferProcedureOutput < AppRouter [ 'post' ] [ 'getById' ] > ; 
Context Type Inference 

import { inferAsyncReturnType } from '@trpc/server' ; export async function createContext ( ) { return { db : prisma , user : null as User | null , } ; } export type Context = inferAsyncReturnType < typeof createContext > ; const t = initTRPC . context < Context > ( ) . create ( ) ; 
Generic Procedures 

// Reusable pagination function createPaginatedProcedure < T > ( getData : ( cursor : number , limit : number ) => Promise < T [ ] > ) { return t . procedure . input ( z . object ( { cursor : z . number ( ) . optional ( ) , limit : z . number ( ) . default ( 10 ) , } ) ) . query ( async ( { input } ) => { const items = await getData ( input . cursor ?? 0 , input . limit ) ; return { items , nextCursor : items . length === input . limit ? ( input . cursor ?? 0 ) + input . limit : undefined , } ; } ) ; } const router = t . router ( { posts : createPaginatedProcedure ( ( cursor , limit ) => db . post . findMany ( { skip : cursor , take : limit } ) ) , users : createPaginatedProcedure ( ( cursor , limit ) => db . user . findMany ( { skip : cursor , take : limit } ) ) , } ) ; 
Testing Strategies 
Unit Testing Procedures 

import { createCaller } from '../routers' ; describe ( 'User Router' , ( ) => { it ( 'should create user' , async ( ) => { const ctx = { db : mockDb , session : null , } ; const caller = createCaller ( ctx ) ; const result = await caller . user . create ( { name : 'Alice' , email : 'alice@example.com' , } ) ; expect ( result ) . toMatchObject ( { name : 'Alice' , email : 'alice@example.com' , } ) ; } ) ; } ) ; 
Integration Testing 

import { httpBatchLink } from '@trpc/client' ; import { createTRPCProxyClient } from '@trpc/client' ; import type { AppRouter } from '../server' ; describe ( 'tRPC Integration' , ( ) => { const client = createTRPCProxyClient < AppRouter > ( { links : [ httpBatchLink ( { url : 'http://localhost:3000/api/trpc' , } ) , ] , } ) ; it ( 'should fetch user' , async ( ) => { const user = await client . user . getById . query ( '123' ) ; expect ( user . id ) . toBe ( '123' ) ; } ) ; } ) ; 
Mocking Context 

import { createCaller } from '../routers' ; const mockContext = { db : { user : { findUnique : vi . fn ( ) . mockResolvedValue ( { id : '1' , name : 'Alice' } ) , create : vi . fn ( ) , } , } , session : { user : { id : '1' , email : 'alice@example.com' } , } , } ; it ( 'should get current user' , async ( ) => { const caller = createCaller ( mockContext ) ; const user = await caller . user . getMe ( ) ; expect ( mockContext . db . user . findUnique ) . toHaveBeenCalledWith ( { where : { id : '1' } , } ) ; expect ( user . name ) . toBe ( 'Alice' ) ; } ) ; 
Testing React Hooks 

import { renderHook , waitFor } from '@testing-library/react' ; import { createWrapper } from './test-utils' ; it ( 'should fetch posts' , async ( ) => { const { result } = renderHook ( ( ) => trpc . post . list . useQuery ( ) , { wrapper : createWrapper ( ) , } ) ; await waitFor ( ( ) => expect ( result . current . isSuccess ) . toBe ( true ) ) ; expect ( result . current . data ) . toHaveLength ( 10 ) ; } ) ; // test-utils.ts import { QueryClient , QueryClientProvider } from '@tanstack/react-query' ; import { httpBatchLink } from '@trpc/client' ; import { trpc } from '../utils/trpc' ; export function createWrapper ( ) { const queryClient = new QueryClient ( ) ; const trpcClient = trpc . createClient ( { links : [ httpBatchLink ( { url : 'http://localhost:3000/api/trpc' } ) ] , } ) ; return ( { children } ) => ( < trpc . Provider client = { trpcClient } queryClient = { queryClient } > < QueryClientProvider client = { queryClient } > { children } < / QueryClientProvider > < / trpc . Provider > ) ; } 
Production Patterns 
Error Monitoring 

import * as Sentry from '@sentry/node' ; const t = initTRPC . context < Context > ( ) . create ( { errorFormatter ( { shape , error } ) { // Log to Sentry if ( error . code === 'INTERNAL_SERVER_ERROR' ) { Sentry . captureException ( error ) ; } return { ... shape , data : { ... shape . data , // Don't expose internal errors in production message : process . env . NODE_ENV === 'production' && error . code === 'INTERNAL_SERVER_ERROR' ? 'Internal server error' : shape . message , } , } ; } , } ) ; 
Rate Limiting 

import { Ratelimit } from '@upstash/ratelimit' ; import { Redis } from '@upstash/redis' ; const ratelimit = new Ratelimit ( { redis : Redis . fromEnv ( ) , limiter : Ratelimit . slidingWindow ( 10 , '10 s' ) , } ) ; const rateLimitMiddleware = t . middleware ( async ( { ctx , next } ) => { const identifier = ctx . session ?. user ?. id ?? ctx . req . ip ; const { success } = await ratelimit . limit ( identifier ) ; if ( ! success ) { throw new TRPCError ( { code : 'TOO_MANY_REQUESTS' , message : 'Rate limit exceeded' , } ) ; } return next ( ) ; } ) ; 
Caching 

import { Redis } from 'ioredis' ; const redis = new Redis ( process . env . REDIS_URL ) ; const router = t . router ( { getUser : t . procedure . input ( z . string ( ) ) . query ( async ( { input } ) => { // Check cache const cached = await redis . get ( ` user: ${ input } ` ) ; if ( cached ) return JSON . parse ( cached ) ; // Fetch from database const user = await db . user . findUnique ( { where : { id : input } } ) ; // Cache for 5 minutes await redis . setex ( ` user: ${ input } ` , 300 , JSON . stringify ( user ) ) ; return user ; } ) , } ) ; 
Request Logging 

const loggingMiddleware = t . middleware ( async ( { path , type , next , input } ) => { const start = Date . now ( ) ; console . log ( ` → ${ type } ${ path } ` , { input } ) ; try { const result = await next ( ) ; const duration = Date . now ( ) - start ; console . log ( ` ✓ ${ type } ${ path } - ${ duration } ms ` ) ; return result ; } catch ( error ) { const duration = Date . now ( ) - start ; console . error ( ` ✗ ${ type } ${ path } - ${ duration } ms ` , { error } ) ; throw error ; } } ) ; 
OpenTelemetry Integration 

import { trace } from '@opentelemetry/api' ; const tracingMiddleware = t . middleware ( async ( { path , type , next } ) => { const tracer = trace . getTracer ( 'trpc' ) ; return tracer . startActiveSpan ( ` trpc. ${ type } . ${ path } ` , async ( span ) => { try { const result = await next ( ) ; span . setStatus ( { code : 0 } ) ; // OK return result ; } catch ( error ) { span . setStatus ( { code : 2 , message : error . message } ) ; // ERROR span . recordException ( error ) ; throw error ; } finally { span . end ( ) ; } } ) ; } ) ; 
Comparison with REST & GraphQL 
Feature Comparison Feature tRPC REST GraphQL Type Safety Full (TypeScript) Manual/codegen Manual/codegen Code Generation None Optional (OpenAPI) Required Learning Curve Low Low Medium/High Client Libraries TypeScript only Any language Any language API Documentation TypeScript types OpenAPI/Swagger Schema/introspection Public APIs ❌ No ✅ Yes ✅ Yes Flexible Queries ❌ Fixed ❌ Fixed ✅ Yes Overfetching Minimal Common None Caching React Query HTTP caching Complex Real-time WebSocket SSE/WebSocket Subscriptions File Uploads Workarounds Native Complex 
When to Choose Each 
tRPC : 
✅ Full-stack TypeScript monorepo 
✅ Internal tools and dashboards 
✅ Next.js applications 
✅ Rapid development with small teams 
❌ Public APIs for external consumers 
❌ Multi-language clients 
REST : 
✅ Public APIs with broad compatibility 
✅ Multi-language services 
✅ HTTP caching requirements 
✅ File uploads and downloads 
❌ Complex nested data structures 
❌ Need for type safety without codegen 
GraphQL : 
✅ Complex data graphs 
✅ Multiple client types (web, mobile, etc.) 
✅ Need for flexible queries 
✅ Avoiding overfetching 
❌ Simple CRUD operations 
❌ Small teams (complexity overhead) 
Migration Path 
tRPC can coexist with REST/GraphQL: 

// Use tRPC for internal, REST for public const router = t . router ( { internal : internalRouter , // tRPC only } ) ; // Expose REST endpoints separately app . get ( '/api/public/users' , publicRestHandler ) ; 
Migration from REST 
Gradual Migration Strategy 
Add tRPC alongside REST : Don't rewrite everything at once 
New features in tRPC : Start with new endpoints 
Migrate high-value endpoints : Focus on complex or frequently used APIs 
Keep public APIs in REST : Only migrate internal consumption 
Converting REST to tRPC 
Before (REST) : 

// pages/api/users/[id].ts export default async function handler ( req , res ) { if ( req . method === 'GET' ) { const user = await db . user . findUnique ( { where : { id : req . query . id } } ) ; res . json ( user ) ; } else if ( req . method === 'PATCH' ) { const user = await db . user . update ( { where : { id : req . query . id } , data : req . body , } ) ; res . json ( user ) ; } } // Client const response = await fetch ( ` /api/users/ ${ id } ` ) ; const user = await response . json ( ) ; // No types! 
After (tRPC) : 

// server/routers/user.ts export const userRouter = t . router ( { getById : t . procedure . input ( z . string ( ) ) . query ( ( { input } ) => db . user . findUnique ( { where : { id : input } } ) ) , update : t . procedure . input ( z . object ( { id : z . string ( ) , data : z . object ( { name : z . string ( ) . optional ( ) } ) , } ) ) . mutation ( ( { input } ) => db . user . update ( { where : { id : input . id } , data : input . data , } ) ) , } ) ; // Client const user = await trpc . user . getById . query ( id ) ; // Fully typed! 
Shared Validation 

// Reuse Zod schemas across REST and tRPC during migration import { createUserSchema } from '../schemas/user' ; // tRPC const router = t . router ( { createUser : t . procedure . input ( createUserSchema ) . mutation ( ( { input } ) => createUser ( input ) ) , } ) ; // REST (validate with same schema) export default async function handler ( req , res ) { const parsed = createUserSchema . safeParse ( req . body ) ; if ( ! parsed . success ) { return res . status ( 400 ) . json ( { errors : parsed . error } ) ; } const user = await createUser ( parsed . data ) ; res . json ( user ) ; } 
Best Practices & Performance 
Code Organization 

server/ ├── trpc.ts              # tRPC instance, base procedures ├── context.ts           # Context creation ├── middleware/ │   ├── auth.ts          # Authentication middleware │   ├── logging.ts       # Logging middleware │   └── rateLimit.ts     # Rate limiting ├── routers/ │   ├── _app.ts          # Root router │   ├── user.ts          # User procedures │   ├── post.ts          # Post procedures │   └── admin/ │       └── index.ts     # Admin-only procedures └── schemas/ ├── user.ts          # User Zod schemas └── post.ts          # Post Zod schemas 
Performance Tips 

Use batching for multiple queries : 

httpBatchLink ( { url : '/api/trpc' , maxBatchSize : 10 } ) 

Implement DataLoader for N+1 queries : 

const userLoader = new DataLoader ( batchLoadUsers ) ; 

Cache expensive queries : 

trpc . posts . list . useQuery ( undefined , { staleTime : 5 * 60 * 1000 } ) ; 

Optimize database queries : 

// ❌ Bad: N+1 query const posts = await db . post . findMany ( ) ; const postsWithAuthors = await Promise . all ( posts . map ( ( p ) => db . user . findUnique ( { where : { id : p . authorId } } ) ) ) ; // ✅ Good: Single query with include const posts = await db . post . findMany ( { include : { author : true } , } ) ; 

Use React Query's deduplication : 

// Multiple components can call same query—React Query deduplicates const { data } = trpc . user . getMe . useQuery ( ) ; 
Security Best Practices 
Always validate input with Zod 
Use middleware for authentication : 

const protectedProcedure = t . procedure . use ( isAuthed ) ; 
Sanitize error messages in production 
Implement rate limiting 
Use HTTPS in production 
Set CORS properly : 

createNextApiHandler ( { router : appRouter , createContext , onError : ( { error } ) => { if ( error . code === 'INTERNAL_SERVER_ERROR' ) { console . error ( 'Internal error:' , error ) ; } } , } ) ; 
Type Safety Tips 

Export router type, not implementation : 

export type AppRouter = typeof appRouter ; // ✅ // Don't export `appRouter` itself to client 

Use 
satisfies for better inference : 

const input = { name : 'Alice' , age : 30 , } satisfies CreateUserInput ; 

Avoid 
any in context : 

// ❌ Bad ctx : { user : any } // ✅ Good ctx : { user : User | null } 
Development Workflow 
Define schema first : Write Zod schemas before procedures 
Test procedures in isolation : Use 
createCaller for unit tests 
Use TypeScript strict mode : Catch type errors early 
Enable React Query DevTools : 

import { ReactQueryDevtools } from '@tanstack/react-query-devtools' ; < ReactQueryDevtools initialIsOpen = { false } / > 
Common Pitfalls 
❌ Don't return sensitive data : 

// Bad: Exposes password hash . query ( ( ) => db . user . findMany ( ) ) // Good: Select specific fields . query ( ( ) => db . user . findMany ( { select : { id : true , name : true } } ) ) 
❌ Don't use mutations for reads : 

// Bad: Side-effect-free operation as mutation getMostRecentPost : t . procedure . mutation ( ( ) => getPost ( ) ) // Good: Use query for reads getMostRecentPost : t . procedure . query ( ( ) => getPost ( ) ) 
❌ Don't skip input validation : 

// Bad: No validation . input ( z . any ( ) ) // Good: Strict validation . input ( z . object ( { id : z . string ( ) . uuid ( ) } ) ) 
Monitoring & Observability 

const t = initTRPC . context < Context > ( ) . create ( { errorFormatter ( { shape , error } ) { // Log metrics metrics . increment ( 'trpc.error' , { code : error . code } ) ; // Send to error tracking if ( error . code === 'INTERNAL_SERVER_ERROR' ) { Sentry . captureException ( error ) ; } return shape ; } , } ) ; const loggingMiddleware = t . middleware ( async ( { path , type , next } ) => { const start = Date . now ( ) ; const result = await next ( ) ; // Log performance metrics metrics . timing ( 'trpc.duration' , Date . now ( ) - start , { path , type } ) ; return result ; } ) ; 
Summary 
tRPC enables type-safe APIs with minimal boilerplate: 
✅ No code generation : Types inferred from TypeScript 
✅ React Query integration : Built-in caching and optimistic updates 
✅ Next.js first-class support : App Router, Server Components 
✅ Developer experience : Auto-complete, refactoring, type errors 
Best for : Full-stack TypeScript apps, Next.js projects, internal tools Avoid for : Public APIs, multi-language services 
Get Started : Install → Define router → Use in client → Enjoy type safety! 
Related Skills : Zod (validation), React Query (caching), Next.js (integration) Weekly Installs 202 Repository bobmatnyc/claude-mpm-skills First Seen Jan 23, 2026 Security Audits Gen Agent Trust Hub Pass Socket Pass Snyk Warn Installed on cursor 138 opencode 121 claude-code 118 gemini-cli 111 codex 110 github-copilot 101