tRPC
Expert assistance with tRPC - End-to-end typesafe APIs with TypeScript.
Overview
tRPC enables building fully typesafe APIs without schemas or code generation:
- Full TypeScript inference from server to client
- No code generation needed
- Excellent DX with autocomplete and type safety
- Works great with Next.js, React Query, and more
Quick Start
Installation
bash1# Core packages 2npm install @trpc/server@next @trpc/client@next @trpc/react-query@next 3 4# Peer dependencies 5npm install @tanstack/react-query@latest zod
Basic Setup (Next.js App Router)
1. Create tRPC Router
typescript1// server/trpc.ts 2import { initTRPC } from '@trpc/server' 3import { z } from 'zod' 4 5const t = initTRPC.create() 6 7export const router = t.router 8export const publicProcedure = t.procedure
2. Define API Router
typescript1// server/routers/_app.ts 2import { router, publicProcedure } from '../trpc' 3import { z } from 'zod' 4 5export const appRouter = router({ 6 hello: publicProcedure 7 .input(z.object({ name: z.string() })) 8 .query(({ input }) => { 9 return { greeting: `Hello ${input.name}!` } 10 }), 11 12 createUser: publicProcedure 13 .input(z.object({ 14 name: z.string(), 15 email: z.string().email(), 16 })) 17 .mutation(async ({ input }) => { 18 const user = await db.user.create({ data: input }) 19 return user 20 }), 21}) 22 23export type AppRouter = typeof appRouter
3. Create API Route
typescript1// app/api/trpc/[trpc]/route.ts 2import { fetchRequestHandler } from '@trpc/server/adapters/fetch' 3import { appRouter } from '@/server/routers/_app' 4 5const handler = (req: Request) => 6 fetchRequestHandler({ 7 endpoint: '/api/trpc', 8 req, 9 router: appRouter, 10 createContext: () => ({}), 11 }) 12 13export { handler as GET, handler as POST }
4. Setup Client Provider
typescript1// app/providers.tsx 2'use client' 3 4import { QueryClient, QueryClientProvider } from '@tanstack/react-query' 5import { httpBatchLink } from '@trpc/client' 6import { useState } from 'react' 7import { trpc } from '@/lib/trpc' 8 9export function Providers({ children }: { children: React.ReactNode }) { 10 const [queryClient] = useState(() => new QueryClient()) 11 const [trpcClient] = useState(() => 12 trpc.createClient({ 13 links: [ 14 httpBatchLink({ 15 url: 'http://localhost:3000/api/trpc', 16 }), 17 ], 18 }) 19 ) 20 21 return ( 22 <trpc.Provider client={trpcClient} queryClient={queryClient}> 23 <QueryClientProvider client={queryClient}> 24 {children} 25 </QueryClientProvider> 26 </trpc.Provider> 27 ) 28}
5. Create tRPC Client
typescript1// lib/trpc.ts 2import { createTRPCReact } from '@trpc/react-query' 3import type { AppRouter } from '@/server/routers/_app' 4 5export const trpc = createTRPCReact<AppRouter>()
6. Use in Components
typescript1'use client' 2 3import { trpc } from '@/lib/trpc' 4 5export default function Home() { 6 const hello = trpc.hello.useQuery({ name: 'World' }) 7 const createUser = trpc.createUser.useMutation() 8 9 return ( 10 <div> 11 <p>{hello.data?.greeting}</p> 12 <button 13 onClick={() => createUser.mutate({ 14 name: 'John', 15 email: 'john@example.com' 16 })} 17 > 18 Create User 19 </button> 20 </div> 21 ) 22}
Router Definition
Basic Router
typescript1import { router, publicProcedure } from './trpc' 2import { z } from 'zod' 3 4export const userRouter = router({ 5 // Query - for fetching data 6 getById: publicProcedure 7 .input(z.string()) 8 .query(async ({ input }) => { 9 return await db.user.findUnique({ where: { id: input } }) 10 }), 11 12 // Mutation - for creating/updating/deleting 13 create: publicProcedure 14 .input(z.object({ 15 name: z.string(), 16 email: z.string().email(), 17 })) 18 .mutation(async ({ input }) => { 19 return await db.user.create({ data: input }) 20 }), 21 22 // Subscription - for real-time updates 23 onUpdate: publicProcedure 24 .subscription(() => { 25 return observable<User>((emit) => { 26 // Implementation 27 }) 28 }), 29})
Nested Routers
typescript1import { router } from './trpc' 2import { userRouter } from './routers/user' 3import { postRouter } from './routers/post' 4import { commentRouter } from './routers/comment' 5 6export const appRouter = router({ 7 user: userRouter, 8 post: postRouter, 9 comment: commentRouter, 10}) 11 12// Usage on client: 13// trpc.user.getById.useQuery('123') 14// trpc.post.list.useQuery() 15// trpc.comment.create.useMutation()
Merging Routers
typescript1import { router, publicProcedure } from './trpc' 2 3const userRouter = router({ 4 list: publicProcedure.query(() => {/* ... */}), 5 getById: publicProcedure.input(z.string()).query(() => {/* ... */}), 6}) 7 8const postRouter = router({ 9 list: publicProcedure.query(() => {/* ... */}), 10 create: publicProcedure.input(z.object({})).mutation(() => {/* ... */}), 11}) 12 13// Merge into app router 14export const appRouter = router({ 15 user: userRouter, 16 post: postRouter, 17})
Input Validation with Zod
Basic Validation
typescript1import { z } from 'zod' 2 3export const userRouter = router({ 4 create: publicProcedure 5 .input(z.object({ 6 name: z.string().min(2).max(50), 7 email: z.string().email(), 8 age: z.number().int().positive().optional(), 9 role: z.enum(['user', 'admin']), 10 })) 11 .mutation(async ({ input }) => { 12 // input is fully typed! 13 return await db.user.create({ data: input }) 14 }), 15})
Complex Validation
typescript1const createPostInput = z.object({ 2 title: z.string().min(5).max(100), 3 content: z.string().min(10), 4 published: z.boolean().default(false), 5 tags: z.array(z.string()).min(1).max(5), 6 metadata: z.object({ 7 views: z.number().default(0), 8 likes: z.number().default(0), 9 }).optional(), 10}) 11 12export const postRouter = router({ 13 create: publicProcedure 14 .input(createPostInput) 15 .mutation(async ({ input }) => { 16 return await db.post.create({ data: input }) 17 }), 18})
Reusable Schemas
typescript1// schemas/user.ts 2export const userSchema = z.object({ 3 id: z.string(), 4 name: z.string(), 5 email: z.string().email(), 6}) 7 8export const createUserSchema = userSchema.omit({ id: true }) 9export const updateUserSchema = userSchema.partial() 10 11// Use in router 12export const userRouter = router({ 13 create: publicProcedure 14 .input(createUserSchema) 15 .mutation(({ input }) => {/* ... */}), 16 17 update: publicProcedure 18 .input(z.object({ 19 id: z.string(), 20 data: updateUserSchema, 21 })) 22 .mutation(({ input }) => {/* ... */}), 23})
Context
Creating Context
typescript1// server/context.ts 2import { inferAsyncReturnType } from '@trpc/server' 3import { FetchCreateContextFnOptions } from '@trpc/server/adapters/fetch' 4 5export async function createContext(opts: FetchCreateContextFnOptions) { 6 // Get session from cookies/headers 7 const session = await getSession(opts.req) 8 9 return { 10 session, 11 db, 12 } 13} 14 15export type Context = inferAsyncReturnType<typeof createContext>
Using Context in tRPC
typescript1// server/trpc.ts 2import { initTRPC } from '@trpc/server' 3import { Context } from './context' 4 5const t = initTRPC.context<Context>().create() 6 7export const router = t.router 8export const publicProcedure = t.procedure
Accessing Context in Procedures
typescript1export const userRouter = router({ 2 me: publicProcedure.query(({ ctx }) => { 3 // ctx.session, ctx.db are available 4 if (!ctx.session) { 5 throw new TRPCError({ code: 'UNAUTHORIZED' }) 6 } 7 8 return ctx.db.user.findUnique({ 9 where: { id: ctx.session.userId } 10 }) 11 }), 12})
Middleware
Creating Middleware
typescript1// server/trpc.ts 2import { initTRPC, TRPCError } from '@trpc/server' 3 4const t = initTRPC.context<Context>().create() 5 6// Logging middleware 7const loggerMiddleware = t.middleware(async ({ path, type, next }) => { 8 const start = Date.now() 9 const result = await next() 10 const duration = Date.now() - start 11 12 console.log(`${type} ${path} took ${duration}ms`) 13 14 return result 15}) 16 17// Auth middleware 18const isAuthed = t.middleware(({ ctx, next }) => { 19 if (!ctx.session) { 20 throw new TRPCError({ code: 'UNAUTHORIZED' }) 21 } 22 23 return next({ 24 ctx: { 25 // Infers session is non-nullable 26 session: ctx.session, 27 }, 28 }) 29}) 30 31// Create procedures with middleware 32export const publicProcedure = t.procedure.use(loggerMiddleware) 33export const protectedProcedure = t.procedure.use(loggerMiddleware).use(isAuthed)
Using Protected Procedures
typescript1export const postRouter = router({ 2 // Public - anyone can access 3 list: publicProcedure.query(() => { 4 return db.post.findMany({ where: { published: true } }) 5 }), 6 7 // Protected - requires authentication 8 create: protectedProcedure 9 .input(z.object({ title: z.string() })) 10 .mutation(({ ctx, input }) => { 11 // ctx.session is guaranteed to exist 12 return db.post.create({ 13 data: { 14 ...input, 15 authorId: ctx.session.userId, 16 }, 17 }) 18 }), 19})
Role-Based Middleware
typescript1const requireRole = (role: string) => 2 t.middleware(({ ctx, next }) => { 3 if (!ctx.session || ctx.session.role !== role) { 4 throw new TRPCError({ code: 'FORBIDDEN' }) 5 } 6 return next() 7 }) 8 9export const adminProcedure = protectedProcedure.use(requireRole('admin')) 10 11export const userRouter = router({ 12 delete: adminProcedure 13 .input(z.string()) 14 .mutation(({ input }) => { 15 return db.user.delete({ where: { id: input } }) 16 }), 17})
Client Usage
Queries
typescript1'use client' 2 3import { trpc } from '@/lib/trpc' 4 5export default function UserList() { 6 // Basic query 7 const users = trpc.user.list.useQuery() 8 9 // Query with input 10 const user = trpc.user.getById.useQuery('user-123') 11 12 // Disabled query 13 const profile = trpc.user.getProfile.useQuery( 14 { id: userId }, 15 { enabled: !!userId } 16 ) 17 18 // With options 19 const posts = trpc.post.list.useQuery(undefined, { 20 refetchInterval: 5000, 21 staleTime: 1000, 22 }) 23 24 if (users.isLoading) return <div>Loading...</div> 25 if (users.error) return <div>Error: {users.error.message}</div> 26 27 return ( 28 <ul> 29 {users.data?.map(user => ( 30 <li key={user.id}>{user.name}</li> 31 ))} 32 </ul> 33 ) 34}
Mutations
typescript1'use client' 2 3export default function CreateUser() { 4 const utils = trpc.useContext() 5 6 const createUser = trpc.user.create.useMutation({ 7 onSuccess: () => { 8 // Invalidate and refetch 9 utils.user.list.invalidate() 10 }, 11 onError: (error) => { 12 console.error('Failed to create user:', error) 13 }, 14 }) 15 16 const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => { 17 e.preventDefault() 18 const formData = new FormData(e.currentTarget) 19 20 createUser.mutate({ 21 name: formData.get('name') as string, 22 email: formData.get('email') as string, 23 }) 24 } 25 26 return ( 27 <form onSubmit={handleSubmit}> 28 <input name="name" required /> 29 <input name="email" type="email" required /> 30 <button type="submit" disabled={createUser.isLoading}> 31 {createUser.isLoading ? 'Creating...' : 'Create User'} 32 </button> 33 </form> 34 ) 35}
Optimistic Updates
typescript1const updatePost = trpc.post.update.useMutation({ 2 onMutate: async (newPost) => { 3 // Cancel outgoing refetches 4 await utils.post.list.cancel() 5 6 // Snapshot previous value 7 const previousPosts = utils.post.list.getData() 8 9 // Optimistically update 10 utils.post.list.setData(undefined, (old) => 11 old?.map(post => 12 post.id === newPost.id ? { ...post, ...newPost } : post 13 ) 14 ) 15 16 return { previousPosts } 17 }, 18 onError: (err, newPost, context) => { 19 // Rollback on error 20 utils.post.list.setData(undefined, context?.previousPosts) 21 }, 22 onSettled: () => { 23 // Refetch after success or error 24 utils.post.list.invalidate() 25 }, 26})
Infinite Queries
typescript1// Server 2export const postRouter = router({ 3 list: publicProcedure 4 .input(z.object({ 5 cursor: z.string().optional(), 6 limit: z.number().min(1).max(100).default(10), 7 })) 8 .query(async ({ input }) => { 9 const posts = await db.post.findMany({ 10 take: input.limit + 1, 11 cursor: input.cursor ? { id: input.cursor } : undefined, 12 }) 13 14 let nextCursor: string | undefined = undefined 15 if (posts.length > input.limit) { 16 const nextItem = posts.pop() 17 nextCursor = nextItem!.id 18 } 19 20 return { posts, nextCursor } 21 }), 22}) 23 24// Client 25export default function InfinitePosts() { 26 const posts = trpc.post.list.useInfiniteQuery( 27 { limit: 10 }, 28 { 29 getNextPageParam: (lastPage) => lastPage.nextCursor, 30 } 31 ) 32 33 return ( 34 <div> 35 {posts.data?.pages.map((page, i) => ( 36 <div key={i}> 37 {page.posts.map(post => ( 38 <div key={post.id}>{post.title}</div> 39 ))} 40 </div> 41 ))} 42 43 <button 44 onClick={() => posts.fetchNextPage()} 45 disabled={!posts.hasNextPage || posts.isFetchingNextPage} 46 > 47 {posts.isFetchingNextPage ? 'Loading...' : 'Load More'} 48 </button> 49 </div> 50 ) 51}
Error Handling
Server Errors
typescript1import { TRPCError } from '@trpc/server' 2 3export const postRouter = router({ 4 getById: publicProcedure 5 .input(z.string()) 6 .query(async ({ input }) => { 7 const post = await db.post.findUnique({ where: { id: input } }) 8 9 if (!post) { 10 throw new TRPCError({ 11 code: 'NOT_FOUND', 12 message: 'Post not found', 13 }) 14 } 15 16 return post 17 }), 18 19 create: protectedProcedure 20 .input(z.object({ title: z.string() })) 21 .mutation(async ({ ctx, input }) => { 22 if (!ctx.session.verified) { 23 throw new TRPCError({ 24 code: 'FORBIDDEN', 25 message: 'Email must be verified', 26 }) 27 } 28 29 try { 30 return await db.post.create({ data: input }) 31 } catch (error) { 32 throw new TRPCError({ 33 code: 'INTERNAL_SERVER_ERROR', 34 message: 'Failed to create post', 35 cause: error, 36 }) 37 } 38 }), 39})
Error Codes
BAD_REQUEST- Invalid inputUNAUTHORIZED- Not authenticatedFORBIDDEN- Not authorizedNOT_FOUND- Resource not foundTIMEOUT- Request timeoutCONFLICT- Resource conflictPRECONDITION_FAILED- Precondition check failedPAYLOAD_TOO_LARGE- Request too largeMETHOD_NOT_SUPPORTED- HTTP method not supportedTOO_MANY_REQUESTS- Rate limitedCLIENT_CLOSED_REQUEST- Client closed requestINTERNAL_SERVER_ERROR- Server error
Client Error Handling
typescript1const createPost = trpc.post.create.useMutation({ 2 onError: (error) => { 3 if (error.data?.code === 'UNAUTHORIZED') { 4 router.push('/login') 5 } else if (error.data?.code === 'FORBIDDEN') { 6 alert('You do not have permission') 7 } else { 8 alert('Something went wrong') 9 } 10 }, 11})
Server-Side Calls
In Server Components
typescript1// app/users/page.tsx 2import { createCaller } from '@/server/routers/_app' 3import { createContext } from '@/server/context' 4 5export default async function UsersPage() { 6 const ctx = await createContext({ req: {} as any }) 7 const caller = createCaller(ctx) 8 9 const users = await caller.user.list() 10 11 return ( 12 <ul> 13 {users.map(user => ( 14 <li key={user.id}>{user.name}</li> 15 ))} 16 </ul> 17 ) 18}
Create Caller
typescript1// server/routers/_app.ts 2export const createCaller = createCallerFactory(appRouter) 3 4// Usage 5const caller = createCaller(ctx) 6const user = await caller.user.getById('123')
Advanced Patterns
Request Batching
typescript1import { httpBatchLink } from '@trpc/client' 2 3const trpcClient = trpc.createClient({ 4 links: [ 5 httpBatchLink({ 6 url: '/api/trpc', 7 maxURLLength: 2083, // Reasonable limit 8 }), 9 ], 10})
Request Deduplication
Automatic with React Query - multiple components requesting same data will only make one request.
Custom Headers
typescript1const trpcClient = trpc.createClient({ 2 links: [ 3 httpBatchLink({ 4 url: '/api/trpc', 5 headers: () => { 6 return { 7 Authorization: `Bearer ${getToken()}`, 8 } 9 }, 10 }), 11 ], 12})
Error Formatting
typescript1// server/trpc.ts 2const t = initTRPC.context<Context>().create({ 3 errorFormatter({ shape, error }) { 4 return { 5 ...shape, 6 data: { 7 ...shape.data, 8 zodError: 9 error.cause instanceof ZodError 10 ? error.cause.flatten() 11 : null, 12 }, 13 } 14 }, 15})
Testing
Testing Procedures
typescript1import { appRouter } from '@/server/routers/_app' 2import { createCaller } from '@/server/routers/_app' 3 4describe('user router', () => { 5 it('creates user', async () => { 6 const ctx = { session: mockSession, db: mockDb } 7 const caller = createCaller(ctx) 8 9 const user = await caller.user.create({ 10 name: 'John', 11 email: 'john@example.com', 12 }) 13 14 expect(user.name).toBe('John') 15 }) 16})
Best Practices
- Use Zod for validation - Always validate inputs
- Keep procedures small - Single responsibility
- Use middleware for auth - Don't repeat auth checks
- Type your context - Full type safety
- Organize routers - Split into logical domains
- Handle errors properly - Use appropriate error codes
- Leverage React Query - Use its caching and refetching
- Batch requests - Enable batching for better performance
- Use optimistic updates - Better UX
- Document procedures - Add JSDoc comments
Resources
- Docs: https://trpc.io
- Next.js Guide: https://trpc.io/docs/nextjs
- Examples: https://github.com/trpc/trpc/tree/main/examples