tRPC gives you end-to-end TypeScript type safety between your server and client without generating code or writing schema files. Your editor knows the API response type before you even call it.
Setup
npm install @trpc/server @trpc/client @trpc/react-query @tanstack/react-query zod
Define Your Router
// server/api/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server'
import { ZodError } from 'zod'
const t = initTRPC.context<{ userId: string | null }>().create({
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError: error.cause instanceof ZodError ? error.cause.flatten() : null
}
}
}
})
export const router = t.router
export const publicProcedure = t.procedure
export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
if (!ctx.userId) throw new TRPCError({ code: 'UNAUTHORIZED' })
return next({ ctx: { userId: ctx.userId } }) // userId is now non-null
})
// server/api/routers/posts.ts
import { z } from 'zod'
import { router, publicProcedure, protectedProcedure } from '../trpc'
export const postsRouter = router({
list: publicProcedure
.input(z.object({
cursor: z.string().optional(),
limit: z.number().min(1).max(100).default(20)
}))
.query(async ({ input }) => {
const posts = await db.posts.findMany({
take: input.limit + 1,
cursor: input.cursor ? { id: input.cursor } : undefined,
orderBy: { createdAt: 'desc' }
})
const hasMore = posts.length > input.limit
return {
posts: posts.slice(0, input.limit),
nextCursor: hasMore ? posts[input.limit - 1].id : null
}
}),
create: protectedProcedure
.input(z.object({
title: z.string().min(1).max(200),
content: z.string().min(10)
}))
.mutation(async ({ ctx, input }) => {
return db.posts.create({
data: { ...input, authorId: ctx.userId }
})
}),
delete: protectedProcedure
.input(z.string())
.mutation(async ({ ctx, input: postId }) => {
const post = await db.posts.findUnique({ where: { id: postId } })
if (!post) throw new TRPCError({ code: 'NOT_FOUND' })
if (post.authorId !== ctx.userId) throw new TRPCError({ code: 'FORBIDDEN' })
return db.posts.delete({ where: { id: postId } })
})
})
// server/api/root.ts
import { router } from './trpc'
import { postsRouter } from './routers/posts'
import { usersRouter } from './routers/users'
export const appRouter = router({
posts: postsRouter,
users: usersRouter
})
export type AppRouter = typeof appRouter
Next.js Route Handler
// app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from '@trpc/server/adapters/fetch'
import { appRouter } from '@/server/api/root'
import { getServerSession } from '@/lib/auth'
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: '/api/trpc',
req,
router: appRouter,
createContext: async () => {
const session = await getServerSession()
return { userId: session?.userId ?? null }
}
})
export { handler as GET, handler as POST }
Client Usage
// lib/trpc.ts
import { createTRPCReact } from '@trpc/react-query'
import type { AppRouter } from '@/server/api/root'
export const trpc = createTRPCReact<AppRouter>()
// components/PostList.tsx
'use client'
import { trpc } from '@/lib/trpc'
export function PostList() {
const { data, isLoading } = trpc.posts.list.useQuery({ limit: 10 })
// data.posts is fully typed -- editor autocompletes every field
return (
<div>
{data?.posts.map(post => (
<article key={post.id}>
<h2>{post.title}</h2> {/* TypeScript knows this exists */}
<p>{post.content}</p>
</article>
))}
</div>
)
}
// Mutation with optimistic update
export function CreatePost() {
const utils = trpc.useUtils()
const create = trpc.posts.create.useMutation({
onSuccess: () => {
utils.posts.list.invalidate() // Refetch list after create
}
})
return (
<button onClick={() => create.mutate({ title: 'Test', content: 'Content here' })}>
Create Post
</button>
)
}
tRPC vs REST for Next.js
| tRPC | REST (Route Handlers) | |
|---|---|---|
| Type safety | End-to-end, automatic | Manual or OpenAPI |
| External consumers | No (TypeScript only) | Yes |
| Learning curve | Low (it's just TypeScript) | Low |
| Flexibility | Less (TypeScript ecosystem) | More (any client) |
| Code generation | None needed | Optional (OpenAPI) |
Use tRPC for: Next.js full-stack apps with TypeScript, internal tools, monorepos where frontend and backend share types.
Use REST when: you need a public API, have mobile/non-TypeScript clients, or the team prefers REST conventions.
The productivity gain from tRPC is real -- renaming a field on the server immediately shows errors in every client that uses it, without running tests or type generation.