TRPC eliminates the entire API layer between your Next.js frontend and backend. No REST endpoints to design, no OpenAPI specs to maintain, no fetch calls to write — just TypeScript functions you call directly from your components with full type safety.
Why tRPC
Traditional API development:
- Define route in
app/api/route.ts - Write types for request and response
- Write fetch call in the frontend
- Keep both sides in sync manually
With tRPC:
- Write a typed procedure on the server
- Call it from the client — TypeScript validates the call
Any change to the server procedure immediately surfaces as a type error in the client. The API contract is enforced by the compiler.
Setup
npm install @trpc/server @trpc/client @trpc/next @trpc/react-query @tanstack/react-query zod
Server Setup
// server/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { z } from 'zod'
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?.user) {
throw new TRPCError({ code: 'UNAUTHORIZED' })
}
return next({ ctx: { ...ctx, user: ctx.session.user } })
})
// server/routers/users.ts
import { router, protectedProcedure } from '../trpc'
import { z } from 'zod'
import { db } from '@/lib/db'
export const usersRouter = router({
getProfile: protectedProcedure.query(async ({ ctx }) => {
return db.user.findUnique({ where: { id: ctx.user.id } })
}),
updateProfile: protectedProcedure
.input(z.object({
name: z.string().min(1).max(100),
bio: z.string().max(500).optional(),
}))
.mutation(async ({ ctx, input }) => {
return db.user.update({
where: { id: ctx.user.id },
data: input,
})
}),
})
App Router Integration
// 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
// components/Profile.tsx
'use client'
import { trpc } from '@/lib/trpc'
export function Profile() {
const { data: profile } = trpc.users.getProfile.useQuery()
const updateProfile = trpc.users.updateProfile.useMutation({
onSuccess: () => trpc.useUtils().users.getProfile.invalidate(),
})
return (
<form onSubmit={(e) => {
e.preventDefault()
updateProfile.mutate({ name: 'New Name' })
}}>
<input defaultValue={profile?.name} />
<button type='submit'>Save</button>
</form>
)
}
TypeScript will error if you call updateProfile.mutate({ namee: 'typo' }) — the Zod schema is the single source of truth for both validation and types.
Server-Side Calls
Call tRPC procedures directly in Server Components (no HTTP round-trip):
// app/dashboard/page.tsx
import { createCaller } from '@/server/routers/_app'
import { createContext } from '@/server/context'
export default async function Dashboard() {
const ctx = await createContext()
const caller = createCaller(ctx)
const profile = await caller.users.getProfile()
return <div>{profile.name}</div>
}
Error Handling
tRPC maps error codes to HTTP status codes automatically:
import { TRPCError } from '@trpc/server'
// In a procedure
if (!resource) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Resource not found' })
}
if (!hasPermission) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Access denied' })
}
On the client, error.data.code gives you the tRPC error code, error.message gives the human-readable message.
The AI SaaS Starter at whoffagents.com ships with tRPC pre-configured alongside NextAuth and Prisma — protected procedures, context setup, and client hooks all wired. $99 one-time.
Top comments (0)