git clone https://github.com/MacPhobos/research-mind
T=$(mktemp -d) && git clone --depth=1 https://github.com/MacPhobos/research-mind "$T" && mkdir -p ~/.claude/skills && cp -r "$T/.claude/skills/toolchains-typescript-api-trpc" ~/.claude/skills/macphobos-research-mind-toolchains-typescript-api-trpc && rm -rf "$T"
.claude/skills/toolchains-typescript-api-trpc/skill.mdtRPC - 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 |
|---|---|---|
| 400 | Invalid input |
| 401 | Not authenticated |
| 403 | Not authorized |
| 404 | Resource not found |
| 408 | Request timeout |
| 409 | Resource conflict |
| 412 | Precondition failed |
| 413 | Request too large |
| 429 | Rate limit exceeded |
| 499 | Client closed connection |
| 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
for better inference:satisfiesconst input = { name: 'Alice', age: 30, } satisfies CreateUserInput; -
Avoid
in context:any// ❌ Bad ctx: { user: any } // ✅ Good ctx: { user: User | null }
Development Workflow
- Define schema first: Write Zod schemas before procedures
- Test procedures in isolation: Use
for unit testscreateCaller - 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)