Install
Terminal · npx$
npx skills add https://github.com/bobmatnyc/claude-mpm-skills --skill trpc-type-safetyWorks with Paperclip
How Trpc Type Safety fits into a Paperclip company.
Trpc Type Safety drops into any Paperclip agent that handles this kind of work. Assign it to a specialist inside a pre-configured PaperclipOrg company and the skill becomes available on every heartbeat — no prompt engineering, no tool wiring.
S
SaaS FactoryPaired
Pre-configured AI company — 18 agents, 18 skills, one-time purchase.
$27$59
Explore packSource file
SKILL.md2102 linesExpandCollapse
---name: trpc-type-safetydescription: "tRPC end-to-end type-safe APIs for TypeScript with React Query integration and full-stack type safety"user-invocable: falsedisable-model-invocation: trueprogressive_disclosure: entry_point: summary: "tRPC end-to-end type-safe APIs for TypeScript with React Query integration and full-stack type safety" when_to_use: "When working with trpc-type-safety or related functionality." quick_start: "1. Review the core concepts below. 2. Apply patterns to your use case. 3. Follow best practices for implementation."---# 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 ```bash# Server dependenciesnpm install @trpc/server zod # React/Next.js client dependenciesnpm install @trpc/client @trpc/react-query @tanstack/react-query``` ### Define Router (Server) ```typescript// server/trpc.tsimport { 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) ```typescript// client/trpc.tsimport { createTRPCReact } from '@trpc/react-query';import type { AppRouter } from '../server/trpc'; export const trpc = createTRPCReact<AppRouter>(); // Componentfunction 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 1. **Router**: Collection of procedures (API endpoints)2. **Procedure**: Single API operation (query or mutation)3. **Context**: Request-scoped data (user, database, etc.)4. **Middleware**: Intercept/modify requests (auth, logging)5. **Input/Output**: Validated with Zod schemas ### Type Flow ```typescript// Server defines typesconst router = t.router({ getUser: t.procedure .input(z.string()) .query(({ input }) => ({ id: input, name: 'Alice' })),}); // Client gets automatic typesconst 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 ```typescriptimport { initTRPC } from '@trpc/server'; const t = initTRPC.create(); export const appRouter = t.router({ // Procedures go here}); export type AppRouter = typeof appRouter;``` ### Nested Routers (Namespacing) ```typescriptconst 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 ```typescriptimport { 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) ```typescriptconst 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) ```typescriptconst 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 ```typescriptconst 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 ```typescriptimport { 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 ```typescriptconst 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 ```typescriptconst 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 ```typescript// schemas/user.tsexport const CreateUserSchema = z.object({ name: z.string(), email: z.string().email(),}); export const UpdateUserSchema = CreateUserSchema.partial().extend({ id: z.string(),}); // routers/user.tsconst 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 ```typescriptimport { 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 ```typescriptconst 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 ```typescript// ✅ Good: Lazy database connectionexport async function createContext(opts: CreateNextContextOptions) { return { getDB: () => prisma, // Lazy session: await getSession(opts.req), };} // ❌ Bad: Heavy computation in contextexport 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 ```typescriptconst 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 ```typescriptconst 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 builderconst 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 ```typescriptconst 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 ```typescriptconst 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 ```typescriptimport { 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 ```typescriptconst 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 ```typescriptconst 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 ```typescriptfunction 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 ```typescriptimport { createTRPCProxyClient, httpBatchLink } from '@trpc/client';import type { AppRouter } from './server'; const client = createTRPCProxyClient<AppRouter>({ links: [ httpBatchLink({ url: 'http://localhost:3000/api/trpc', }), ],}); // Usageconst user = await client.user.getById.query('123');const newPost = await client.post.create.mutate({ title: 'Hello' });``` ### React Client Setup ```typescript// utils/trpc.tsimport { createTRPCReact } from '@trpc/react-query';import type { AppRouter } from '../server/routers'; export const trpc = createTRPCReact<AppRouter>(); // _app.tsximport { 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 ```typescript// pages/api/trpc/[trpc].tsimport { createNextApiHandler } from '@trpc/server/adapters/next';import { appRouter } from '../../../server/routers';import { createContext } from '../../../server/context'; export default createNextApiHandler({ router: appRouter, createContext,});``` ### Headers & Authentication ```typescriptconst 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 ```typescriptfunction 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 ```typescriptconst { 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 ```typescriptfunction 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 ```typescriptconst 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 ```typescript// Serverconst 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, }; }),}); // Clientfunction 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 ```typescript// 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 ```typescript// 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 ```typescript// 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.tsximport { Providers } from './providers'; export default function RootLayout({ children }) { return ( <html> <body> <Providers>{children}</Providers> </body> </html> );}``` ### Client Components in App Router ```typescript// 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) ```typescript// app/api/trpc/[trpc]/route.tsimport { 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) ```typescriptimport { 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 ```typescriptimport { 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 ```typescriptimport { createWSClient, wsLink } from '@trpc/client'; const wsClient = createWSClient({ url: 'ws://localhost:3001',}); const trpcClient = trpc.createClient({ links: [ wsLink({ client: wsClient, }), ],});``` ### useSubscription Hook ```typescriptfunction 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 ```typescript// Serverconst 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); }); }),}); // Clienttrpc.onUserStatusChange.useSubscription('user-123', { onData: (status) => console.log('Status:', status),});``` --- ## File Uploads ### Multipart Form Data (Server) ```typescript// Next.js API route with file uploadimport { 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) ```typescript// 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 }; }),}); // Clientconst 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) ```typescript// Step 1: Get signed upload URL from tRPCconst 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 S3async 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 ```typescript// Client configurationconst 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 requestconst 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 ```typescriptimport DataLoader from 'dataloader'; // Create DataLoader in contextexport 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 proceduresconst 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 ```typescriptimport { 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 ```typescriptimport type { inferRouterInputs, inferRouterOutputs } from '@trpc/server';import type { AppRouter } from './server'; // Input typestype RouterInputs = inferRouterInputs<AppRouter>;type CreateUserInput = RouterInputs['user']['create']; // Output typestype RouterOutputs = inferRouterOutputs<AppRouter>;type User = RouterOutputs['user']['getById']; // Use in componentsfunction UserCard({ user }: { user: User }) { return <div>{user.name}</div>;}``` ### Procedure Helpers ```typescriptimport type { inferProcedureInput, inferProcedureOutput } from '@trpc/server'; type CreatePostInput = inferProcedureInput<AppRouter['post']['create']>;type Post = inferProcedureOutput<AppRouter['post']['getById']>;``` ### Context Type Inference ```typescriptimport { 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 ```typescript// Reusable paginationfunction 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 ```typescriptimport { 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 ```typescriptimport { 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 ```typescriptimport { 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 ```typescriptimport { 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.tsimport { 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 ```typescriptimport * 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 ```typescriptimport { 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 ```typescriptimport { 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 ```typescriptconst 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 ```typescriptimport { 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:```typescript// Use tRPC for internal, REST for publicconst router = t.router({ internal: internalRouter, // tRPC only}); // Expose REST endpoints separatelyapp.get('/api/public/users', publicRestHandler);``` --- ## Migration from REST ### Gradual Migration Strategy 1. **Add tRPC alongside REST**: Don't rewrite everything at once2. **New features in tRPC**: Start with new endpoints3. **Migrate high-value endpoints**: Focus on complex or frequently used APIs4. **Keep public APIs in REST**: Only migrate internal consumption ### Converting REST to tRPC **Before (REST)**:```typescript// pages/api/users/[id].tsexport 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); }} // Clientconst response = await fetch(`/api/users/${id}`);const user = await response.json(); // No types!``` **After (tRPC)**:```typescript// server/routers/user.tsexport 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, })),}); // Clientconst user = await trpc.user.getById.query(id); // Fully typed!``` ### Shared Validation ```typescript// Reuse Zod schemas across REST and tRPC during migrationimport { createUserSchema } from '../schemas/user'; // tRPCconst 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 1. **Use batching for multiple queries**: ```typescript httpBatchLink({ url: '/api/trpc', maxBatchSize: 10 }) ``` 2. **Implement DataLoader for N+1 queries**: ```typescript const userLoader = new DataLoader(batchLoadUsers); ``` 3. **Cache expensive queries**: ```typescript trpc.posts.list.useQuery(undefined, { staleTime: 5 * 60 * 1000 }); ``` 4. **Optimize database queries**: ```typescript // ❌ 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 }, }); ``` 5. **Use React Query's deduplication**: ```typescript // Multiple components can call same query—React Query deduplicates const { data } = trpc.user.getMe.useQuery(); ``` ### Security Best Practices 1. **Always validate input with Zod**2. **Use middleware for authentication**: ```typescript const protectedProcedure = t.procedure.use(isAuthed); ```3. **Sanitize error messages in production**4. **Implement rate limiting**5. **Use HTTPS in production**6. **Set CORS properly**: ```typescript createNextApiHandler({ router: appRouter, createContext, onError: ({ error }) => { if (error.code === 'INTERNAL_SERVER_ERROR') { console.error('Internal error:', error); } }, }); ``` ### Type Safety Tips 1. **Export router type, not implementation**: ```typescript export type AppRouter = typeof appRouter; // ✅ // Don't export `appRouter` itself to client ``` 2. **Use `satisfies` for better inference**: ```typescript const input = { name: 'Alice', age: 30, } satisfies CreateUserInput; ``` 3. **Avoid `any` in context**: ```typescript // ❌ Bad ctx: { user: any } // ✅ Good ctx: { user: User | null } ``` ### Development Workflow 1. **Define schema first**: Write Zod schemas before procedures2. **Test procedures in isolation**: Use `createCaller` for unit tests3. **Use TypeScript strict mode**: Catch type errors early4. **Enable React Query DevTools**: ```typescript import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; <ReactQueryDevtools initialIsOpen={false} /> ``` ### Common Pitfalls ❌ **Don't return sensitive data**:```typescript// 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**:```typescript// Bad: Side-effect-free operation as mutationgetMostRecentPost: t.procedure.mutation(() => getPost()) // Good: Use query for readsgetMostRecentPost: t.procedure.query(() => getPost())``` ❌ **Don't skip input validation**:```typescript// Bad: No validation.input(z.any()) // Good: Strict validation.input(z.object({ id: z.string().uuid() }))``` ### Monitoring & Observability ```typescriptconst 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)