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
Core packages
npm install @trpc/server@next @trpc/client@next @trpc/react-query@next
Peer dependencies
npm install @tanstack/react-query@latest zod
Basic Setup (Next.js App Router)
- Create tRPC Router
// server/trpc.ts import { initTRPC } from '@trpc/server' import { z } from 'zod'
const t = initTRPC.create()
export const router = t.router export const publicProcedure = t.procedure
- Define API Router
// server/routers/_app.ts import { router, publicProcedure } from '../trpc' import { z } from 'zod'
export const appRouter = router({
hello: publicProcedure
.input(z.object({ name: z.string() }))
.query(({ input }) => {
return { greeting: Hello ${input.name}! }
}),
createUser: publicProcedure .input(z.object({ name: z.string(), email: z.string().email(), })) .mutation(async ({ input }) => { const user = await db.user.create({ data: input }) return user }), })
export type AppRouter = typeof appRouter
- Create API Route
// app/api/trpc/[trpc]/route.ts import { fetchRequestHandler } from '@trpc/server/adapters/fetch' import { appRouter } from '@/server/routers/_app'
const handler = (req: Request) => fetchRequestHandler({ endpoint: '/api/trpc', req, router: appRouter, createContext: () => ({}), })
export { handler as GET, handler as POST }
- Setup Client 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 '@/lib/trpc'
export function Providers({ children }: { children: React.ReactNode }) { 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}> {children} </QueryClientProvider> </trpc.Provider> ) }
- Create tRPC Client
// lib/trpc.ts import { createTRPCReact } from '@trpc/react-query' import type { AppRouter } from '@/server/routers/_app'
export const trpc = createTRPCReact<AppRouter>()
- Use in Components
'use client'
import { trpc } from '@/lib/trpc'
export default function Home() { const hello = trpc.hello.useQuery({ name: 'World' }) const createUser = trpc.createUser.useMutation()
return ( <div> <p>{hello.data?.greeting}</p> <button onClick={() => createUser.mutate({ name: 'John', email: 'john@example.com' })} > Create User </button> </div> ) }
Router Definition
Basic Router
import { router, publicProcedure } from './trpc' import { z } from 'zod'
export const userRouter = router({ // Query - for fetching data getById: publicProcedure .input(z.string()) .query(async ({ input }) => { return await db.user.findUnique({ where: { id: input } }) }),
// Mutation - for creating/updating/deleting create: publicProcedure .input(z.object({ name: z.string(), email: z.string().email(), })) .mutation(async ({ input }) => { return await db.user.create({ data: input }) }),
// Subscription - for real-time updates onUpdate: publicProcedure .subscription(() => { return observable<User>((emit) => { // Implementation }) }), })
Nested Routers
import { router } from './trpc' import { userRouter } from './routers/user' import { postRouter } from './routers/post' import { commentRouter } from './routers/comment'
export const appRouter = router({ user: userRouter, post: postRouter, comment: commentRouter, })
// Usage on client: // trpc.user.getById.useQuery('123') // trpc.post.list.useQuery() // trpc.comment.create.useMutation()
Merging Routers
import { router, publicProcedure } from './trpc'
const userRouter = router({ list: publicProcedure.query(() => {/* ... /}), getById: publicProcedure.input(z.string()).query(() => {/ ... */}), })
const postRouter = router({ list: publicProcedure.query(() => {/* ... /}), create: publicProcedure.input(z.object({})).mutation(() => {/ ... */}), })
// Merge into app router export const appRouter = router({ user: userRouter, post: postRouter, })
Input Validation with Zod
Basic Validation
import { z } from 'zod'
export const userRouter = router({ create: publicProcedure .input(z.object({ name: z.string().min(2).max(50), email: z.string().email(), age: z.number().int().positive().optional(), role: z.enum(['user', 'admin']), })) .mutation(async ({ input }) => { // input is fully typed! return await db.user.create({ data: input }) }), })
Complex Validation
const createPostInput = z.object({ title: z.string().min(5).max(100), content: z.string().min(10), published: z.boolean().default(false), tags: z.array(z.string()).min(1).max(5), metadata: z.object({ views: z.number().default(0), likes: z.number().default(0), }).optional(), })
export const postRouter = router({ create: publicProcedure .input(createPostInput) .mutation(async ({ input }) => { return await db.post.create({ data: input }) }), })
Reusable Schemas
// schemas/user.ts export const userSchema = z.object({ id: z.string(), name: z.string(), email: z.string().email(), })
export const createUserSchema = userSchema.omit({ id: true }) export const updateUserSchema = userSchema.partial()
// Use in router export const userRouter = router({ create: publicProcedure .input(createUserSchema) .mutation(({ input }) => {/* ... */}),
update: publicProcedure .input(z.object({ id: z.string(), data: updateUserSchema, })) .mutation(({ input }) => {/* ... */}), })
Context
Creating Context
// server/context.ts import { inferAsyncReturnType } from '@trpc/server' import { FetchCreateContextFnOptions } from '@trpc/server/adapters/fetch'
export async function createContext(opts: FetchCreateContextFnOptions) { // Get session from cookies/headers const session = await getSession(opts.req)
return { session, db, } }
export type Context = inferAsyncReturnType<typeof createContext>
Using Context in tRPC
// server/trpc.ts import { initTRPC } from '@trpc/server' import { Context } from './context'
const t = initTRPC.context<Context>().create()
export const router = t.router export const publicProcedure = t.procedure
Accessing Context in Procedures
export const userRouter = router({ me: publicProcedure.query(({ ctx }) => { // ctx.session, ctx.db are available if (!ctx.session) { throw new TRPCError({ code: 'UNAUTHORIZED' }) }
return ctx.db.user.findUnique({
where: { id: ctx.session.userId }
})
}), })
Middleware
Creating Middleware
// server/trpc.ts import { initTRPC, TRPCError } from '@trpc/server'
const t = initTRPC.context<Context>().create()
// Logging middleware const loggerMiddleware = t.middleware(async ({ path, type, next }) => { const start = Date.now() const result = await next() const duration = Date.now() - start
console.log(${type} ${path} took ${duration}ms)
return result })
// Auth middleware const isAuthed = t.middleware(({ ctx, next }) => { if (!ctx.session) { throw new TRPCError({ code: 'UNAUTHORIZED' }) }
return next({ ctx: { // Infers session is non-nullable session: ctx.session, }, }) })
// Create procedures with middleware export const publicProcedure = t.procedure.use(loggerMiddleware) export const protectedProcedure = t.procedure.use(loggerMiddleware).use(isAuthed)
Using Protected Procedures
export const postRouter = router({ // Public - anyone can access list: publicProcedure.query(() => { return db.post.findMany({ where: { published: true } }) }),
// Protected - requires authentication create: protectedProcedure .input(z.object({ title: z.string() })) .mutation(({ ctx, input }) => { // ctx.session is guaranteed to exist return db.post.create({ data: { ...input, authorId: ctx.session.userId, }, }) }), })
Role-Based Middleware
const requireRole = (role: string) => t.middleware(({ ctx, next }) => { if (!ctx.session || ctx.session.role !== role) { throw new TRPCError({ code: 'FORBIDDEN' }) } return next() })
export const adminProcedure = protectedProcedure.use(requireRole('admin'))
export const userRouter = router({ delete: adminProcedure .input(z.string()) .mutation(({ input }) => { return db.user.delete({ where: { id: input } }) }), })
Client Usage
Queries
'use client'
import { trpc } from '@/lib/trpc'
export default function UserList() { // Basic query const users = trpc.user.list.useQuery()
// Query with input const user = trpc.user.getById.useQuery('user-123')
// Disabled query const profile = trpc.user.getProfile.useQuery( { id: userId }, { enabled: !!userId } )
// With options const posts = trpc.post.list.useQuery(undefined, { refetchInterval: 5000, staleTime: 1000, })
if (users.isLoading) return <div>Loading...</div> if (users.error) return <div>Error: {users.error.message}</div>
return ( <ul> {users.data?.map(user => ( <li key={user.id}>{user.name}</li> ))} </ul> ) }
Mutations
'use client'
export default function CreateUser() { const utils = trpc.useContext()
const createUser = trpc.user.create.useMutation({ onSuccess: () => { // Invalidate and refetch utils.user.list.invalidate() }, onError: (error) => { console.error('Failed to create user:', error) }, })
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => { e.preventDefault() const formData = new FormData(e.currentTarget)
createUser.mutate({
name: formData.get('name') as string,
email: formData.get('email') as string,
})
}
return ( <form onSubmit={handleSubmit}> <input name="name" required /> <input name="email" type="email" required /> <button type="submit" disabled={createUser.isLoading}> {createUser.isLoading ? 'Creating...' : 'Create User'} </button> </form> ) }
Optimistic Updates
const updatePost = trpc.post.update.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?.map(post =>
post.id === newPost.id ? { ...post, ...newPost } : post
)
)
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 export const postRouter = router({ list: publicProcedure .input(z.object({ cursor: z.string().optional(), limit: z.number().min(1).max(100).default(10), })) .query(async ({ input }) => { const posts = await db.post.findMany({ take: input.limit + 1, cursor: input.cursor ? { id: input.cursor } : undefined, })
let nextCursor: string | undefined = undefined
if (posts.length > input.limit) {
const nextItem = posts.pop()
nextCursor = nextItem!.id
}
return { posts, nextCursor }
}),
})
// Client export default function InfinitePosts() { const posts = trpc.post.list.useInfiniteQuery( { limit: 10 }, { getNextPageParam: (lastPage) => lastPage.nextCursor, } )
return ( <div> {posts.data?.pages.map((page, i) => ( <div key={i}> {page.posts.map(post => ( <div key={post.id}>{post.title}</div> ))} </div> ))}
<button
onClick={() => posts.fetchNextPage()}
disabled={!posts.hasNextPage || posts.isFetchingNextPage}
>
{posts.isFetchingNextPage ? 'Loading...' : 'Load More'}
</button>
</div>
) }
Error Handling
Server Errors
import { TRPCError } from '@trpc/server'
export const postRouter = router({ getById: publicProcedure .input(z.string()) .query(async ({ input }) => { const post = await db.post.findUnique({ where: { id: input } })
if (!post) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Post not found',
})
}
return post
}),
create: protectedProcedure .input(z.object({ title: z.string() })) .mutation(async ({ ctx, input }) => { if (!ctx.session.verified) { throw new TRPCError({ code: 'FORBIDDEN', message: 'Email must be verified', }) }
try {
return await db.post.create({ data: input })
} catch (error) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Failed to create post',
cause: error,
})
}
}),
})
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
const createPost = trpc.post.create.useMutation({ onError: (error) => { if (error.data?.code === 'UNAUTHORIZED') { router.push('/login') } else if (error.data?.code === 'FORBIDDEN') { alert('You do not have permission') } else { alert('Something went wrong') } }, })
Server-Side Calls
In Server Components
// app/users/page.tsx import { createCaller } from '@/server/routers/_app' import { createContext } from '@/server/context'
export default async function UsersPage() { const ctx = await createContext({ req: {} as any }) const caller = createCaller(ctx)
const users = await caller.user.list()
return ( <ul> {users.map(user => ( <li key={user.id}>{user.name}</li> ))} </ul> ) }
Create Caller
// server/routers/_app.ts export const createCaller = createCallerFactory(appRouter)
// Usage const caller = createCaller(ctx) const user = await caller.user.getById('123')
Advanced Patterns
Request Batching
import { httpBatchLink } from '@trpc/client'
const trpcClient = trpc.createClient({ links: [ httpBatchLink({ url: '/api/trpc', maxURLLength: 2083, // Reasonable limit }), ], })
Request Deduplication
Automatic with React Query - multiple components requesting same data will only make one request.
Custom Headers
const trpcClient = trpc.createClient({
links: [
httpBatchLink({
url: '/api/trpc',
headers: () => {
return {
Authorization: Bearer ${getToken()},
}
},
}),
],
})
Error Formatting
// server/trpc.ts const t = initTRPC.context<Context>().create({ errorFormatter({ shape, error }) { return { ...shape, data: { ...shape.data, zodError: error.cause instanceof ZodError ? error.cause.flatten() : null, }, } }, })
Testing
Testing Procedures
import { appRouter } from '@/server/routers/_app' import { createCaller } from '@/server/routers/_app'
describe('user router', () => { it('creates user', async () => { const ctx = { session: mockSession, db: mockDb } const caller = createCaller(ctx)
const user = await caller.user.create({
name: 'John',
email: 'john@example.com',
})
expect(user.name).toBe('John')
}) })
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