tRPC End-to-End Type Safety: Build APIs That Can't Break at Runtime
tRPC eliminates the gap between your backend types and frontend code.
No code generation. No schemas. Just TypeScript.
The Problem tRPC Solves
With REST:
// Backend returns this
{ id: string, name: string, email: string }
// Frontend assumes this (no guarantee)
const user = await fetch('/api/user').then(r => r.json()) // type: any
user.naem // typo — no error until runtime
With tRPC:
// Same type, automatically
const user = await trpc.user.get.query({ id: '1' }) // type: User
user.naem // TypeScript error immediately
Setup
npm install @trpc/server @trpc/client @trpc/react-query @trpc/next zod
// server/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server'
import { getServerSession } from 'next-auth'
const t = initTRPC.context<Context>().create()
export const router = t.router
export const publicProcedure = t.procedure
export const protectedProcedure = t.procedure.use(async ({ ctx, next }) => {
if (!ctx.session) throw new TRPCError({ code: 'UNAUTHORIZED' })
return next({ ctx: { ...ctx, session: ctx.session } })
})
Defining Procedures
// server/routers/user.ts
import { z } from 'zod'
import { router, protectedProcedure, publicProcedure } from '../trpc'
export const userRouter = router({
// Query (GET equivalent)
get: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input, ctx }) => {
return ctx.db.user.findUniqueOrThrow({ where: { id: input.id } })
}),
// Mutation (POST/PUT/DELETE equivalent)
update: protectedProcedure
.input(z.object({
name: z.string().min(1).max(100),
bio: z.string().max(500).optional(),
}))
.mutation(async ({ input, ctx }) => {
return ctx.db.user.update({
where: { id: ctx.session.user.id },
data: input,
})
}),
// List with pagination
list: publicProcedure
.input(z.object({
cursor: z.string().optional(),
limit: z.number().min(1).max(100).default(20),
}))
.query(async ({ input, ctx }) => {
const users = await ctx.db.user.findMany({
take: input.limit + 1,
cursor: input.cursor ? { id: input.cursor } : undefined,
})
const nextCursor = users.length > input.limit ? users.pop()?.id : undefined
return { users, nextCursor }
}),
})
Root Router
// server/routers/_app.ts
import { router } from '../trpc'
import { userRouter } from './user'
import { postRouter } from './post'
export const appRouter = router({
user: userRouter,
post: postRouter,
})
export type AppRouter = typeof appRouter
Next.js API Handler
// app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from '@trpc/server/adapters/fetch'
import { appRouter } from '@/server/routers/_app'
import { createContext } from '@/server/context'
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: '/api/trpc',
req,
router: appRouter,
createContext,
})
export { handler as GET, handler as POST }
Client Usage
// utils/trpc.ts
import { createTRPCReact } from '@trpc/react-query'
import type { AppRouter } from '@/server/routers/_app'
export const trpc = createTRPCReact<AppRouter>()
// components/UserProfile.tsx
function UserProfile({ userId }: { userId: string }) {
// Fully typed - IntelliSense shows all available fields
const { data: user } = trpc.user.get.useQuery({ id: userId })
const updateUser = trpc.user.update.useMutation({
onSuccess: () => {
trpc.useContext().user.get.invalidate({ id: userId })
},
})
return (
<div>
<h1>{user?.name}</h1>
<button onClick={() => updateUser.mutate({ name: 'New Name' })}>
Update
</button>
</div>
)
}
Input Validation with Zod
tRPC uses Zod for runtime validation — same schema validates and types simultaneously:
const CreatePostInput = z.object({
title: z.string().min(1, 'Title required').max(200),
content: z.string().min(10, 'Too short').max(10000),
tags: z.array(z.string()).max(5),
publishAt: z.date().optional(),
})
type CreatePostInput = z.infer<typeof CreatePostInput>
// Same type in procedure and frontend form
createPost: protectedProcedure
.input(CreatePostInput)
.mutation(async ({ input }) => { /* input is fully typed */ })
Error Handling
// Server
if (!post) throw new TRPCError({
code: 'NOT_FOUND',
message: 'Post not found',
})
// Client
const { data, error } = trpc.post.get.useQuery({ id })
if (error?.data?.code === 'NOT_FOUND') return <NotFound />
The AI SaaS Starter Kit comes with tRPC fully configured: router, context, auth middleware, and React Query integration. $99 one-time — production-ready.
Top comments (0)