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
bash
1# 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
typescript
1// 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
typescript
1// 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
typescript
1// 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
typescript
1// 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
typescript
1// 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
typescript
1'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
typescript
1import { 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
typescript
1import { 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
typescript
1import { 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})
Basic Validation
typescript
1import { 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
typescript
1const 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
typescript
1// 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
typescript
1// 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
typescript
1// 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
typescript
1export 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
typescript
1// 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
typescript
1export 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
typescript
1const 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
typescript
1'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
typescript
1'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
typescript
1const 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
typescript
1// 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
typescript
1import { 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 input
UNAUTHORIZED - Not authenticated
FORBIDDEN - Not authorized
NOT_FOUND - Resource not found
TIMEOUT - Request timeout
CONFLICT - Resource conflict
PRECONDITION_FAILED - Precondition check failed
PAYLOAD_TOO_LARGE - Request too large
METHOD_NOT_SUPPORTED - HTTP method not supported
TOO_MANY_REQUESTS - Rate limited
CLIENT_CLOSED_REQUEST - Client closed request
INTERNAL_SERVER_ERROR - Server error
Client Error Handling
typescript
1const 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
typescript
1// 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
typescript
1// 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
typescript
1import { 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.
typescript
1const trpcClient = trpc.createClient({
2 links: [
3 httpBatchLink({
4 url: '/api/trpc',
5 headers: () => {
6 return {
7 Authorization: `Bearer ${getToken()}`,
8 }
9 },
10 }),
11 ],
12})
typescript
1// 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
typescript
1import { 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