Skip to main content
Advertisement

17.5 tRPC — Type-Safe Full-Stack API

What is tRPC?

tRPC provides End-to-End type safety between server and client without API schemas or code generation. TypeScript's type inference alone allows the client to automatically recognize server types.

npm install @trpc/server @trpc/client @trpc/react-query @tanstack/react-query zod

Server Setup

// server/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server'
import { z } from 'zod'

// Context type definition
export interface Context {
user: { id: string; role: string } | null
db: DatabaseService
}

// Create tRPC instance
const t = initTRPC.context<Context>().create()

// Exports
export const router = t.router
export const publicProcedure = t.procedure

// Procedure requiring authentication
export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
if (!ctx.user) {
throw new TRPCError({ code: 'UNAUTHORIZED' })
}
return next({ ctx: { ...ctx, user: ctx.user } }) // Guarantees user is non-null
})

// Admin-only procedure
export const adminProcedure = protectedProcedure.use(({ ctx, next }) => {
if (ctx.user.role !== 'admin') {
throw new TRPCError({ code: 'FORBIDDEN' })
}
return next({ ctx })
})
// server/routers/user.router.ts
import { z } from 'zod'
import { router, publicProcedure, protectedProcedure } from '../trpc'

const CreateUserInput = z.object({
email: z.string().email(),
name: z.string().min(2).max(50),
password: z.string().min(8),
})

const UpdateUserInput = z.object({
name: z.string().min(2).max(50).optional(),
})

export const userRouter = router({
// Public queries
getAll: publicProcedure
.input(z.object({ page: z.number().default(1), limit: z.number().default(10) }))
.query(async ({ ctx, input }) => {
return ctx.db.user.findMany({
skip: (input.page - 1) * input.limit,
take: input.limit,
})
}),

getById: publicProcedure
.input(z.string().uuid())
.query(async ({ ctx, input }) => {
const user = await ctx.db.user.findUnique({ where: { id: input } })
if (!user) throw new TRPCError({ code: 'NOT_FOUND' })
return user
}),

// Requires authentication
updateMe: protectedProcedure
.input(UpdateUserInput)
.mutation(async ({ ctx, input }) => {
return ctx.db.user.update({
where: { id: ctx.user.id },
data: input,
})
}),

deleteMe: protectedProcedure
.mutation(async ({ ctx }) => {
await ctx.db.user.delete({ where: { id: ctx.user.id } })
return { success: true }
}),
})
// server/routers/_app.ts
import { router } from '../trpc'
import { userRouter } from './user.router'
import { postRouter } from './post.router'
import { authRouter } from './auth.router'

export const appRouter = router({
user: userRouter,
post: postRouter,
auth: authRouter,
})

// Export type for client use
export type AppRouter = typeof appRouter

Client Setup

// client/api.ts
import { createTRPCReact } from '@trpc/react-query'
import { httpBatchLink } from '@trpc/client'
import type { AppRouter } from '../server/routers/_app'

export const trpc = createTRPCReact<AppRouter>()

// tRPC client — TanStack Query integration
export const trpcClient = trpc.createClient({
links: [
httpBatchLink({
url: '/api/trpc',
headers: () => {
const token = localStorage.getItem('token')
return token ? { authorization: `Bearer ${token}` } : {}
},
}),
],
})

Usage in React Components

// components/UserList.tsx
import { trpc } from '../client/api'

export function UserList() {
// Types fully auto-inferred!
const { data, isLoading, error } = trpc.user.getAll.useQuery({
page: 1,
limit: 10,
})

if (isLoading) return <div>Loading...</div>
if (error) return <div>Error: {error.message}</div>

return (
<ul>
{data?.map(user => (
<li key={user.id}>{user.name}{user.email}</li>
))}
</ul>
)
}

// components/UpdateProfile.tsx
export function UpdateProfile() {
const utils = trpc.useUtils()

const updateMe = trpc.user.updateMe.useMutation({
onSuccess: () => {
// Invalidate cache
utils.user.getAll.invalidate()
},
})

return (
<button
onClick={() => updateMe.mutate({ name: 'New Name' })}
disabled={updateMe.isPending}
>
Change Name
</button>
)
}

Next.js 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: () => createContext(req),
})

export { handler as GET, handler as POST }

Pro Tips

tRPC Error Handling

import { TRPCClientError } from '@trpc/client'
import type { AppRouter } from '../server/routers/_app'

function handleTRPCError(error: unknown) {
if (error instanceof TRPCClientError<AppRouter>) {
// Type-safe error code access
switch (error.data?.code) {
case 'UNAUTHORIZED':
router.push('/login')
break
case 'NOT_FOUND':
router.push('/404')
break
case 'TOO_MANY_REQUESTS':
showRateLimitNotification()
break
default:
showErrorToast(error.message)
}
}
}
Advertisement