Trpc Api
tRPC router setup with Next.js App Router, procedures (query, mutation, subscription), input validation with Zod, middleware and context, React Query integration, error handling, and type inference.
tRPC API Skill
Overview
This skill provides comprehensive guidance for building type-safe APIs with tRPC in Next.js applications. It covers router setup, procedures, validation, middleware, React Query integration, and error handling.
Process
- Set up tRPC server with Next.js: Create tRPC context, initialize tRPC instance, and create API route handler
- Define routers and procedures: Create routers with query, mutation, and subscription procedures
- Add input validation with Zod: Define Zod schemas for procedure inputs with validation rules
- Implement middleware: Create authentication, authorization, logging, and rate limiting middleware
- Integrate React Query client: Set up tRPC React provider with React Query for client-side usage
- Add error handling: Implement custom error classes, error formatting, and client-side error handling
Setup with Next.js App Router
Install Dependencies
npm install @trpc/server @trpc/client @trpc/react-query @trpc/next @tanstack/react-query zod
Create tRPC Context
// server/trpc/context.ts
import { inferAsyncReturnType } from '@trpc/server'
import { CreateNextContextOptions } from '@trpc/server/adapters/next'
import { prisma } from '@/lib/prisma'
export async function createContext(opts: CreateNextContextOptions) {
const { req, res } = opts
// Get user from session/cookie
const user = await getUserFromRequest(req)
return {
req,
res,
prisma,
user,
}
}
export type Context = inferAsyncReturnType<typeof createContext>
Initialize tRPC
// server/trpc/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server'
import { Context } from './context'
import superjson from 'superjson'
const t = initTRPC.context<Context>().create({
transformer: superjson,
})
export const router = t.router
export const publicProcedure = t.procedure
Create Protected Procedure
// server/trpc/trpc.ts
const isAuthenticated = t.middleware(({ ctx, next }) => {
if (!ctx.user) {
throw new TRPCError({ code: 'UNAUTHORIZED' })
}
return next({
ctx: {
...ctx,
user: ctx.user, // Type narrowing
},
})
})
export const protectedProcedure = t.procedure.use(isAuthenticated)
Router Setup
Basic Router
// server/routers/user.ts
import { z } from 'zod'
import { router, publicProcedure, protectedProcedure } from '../trpc/trpc'
import { TRPCError } from '@trpc/server'
export const userRouter = router({
getById: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const user = await ctx.prisma.user.findUnique({
where: { id: input.id },
})
if (!user) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'User not found',
})
}
return user
}),
getAll: publicProcedure.query(async ({ ctx }) => {
return ctx.prisma.user.findMany({
orderBy: { createdAt: 'desc' },
})
}),
create: publicProcedure
.input(
z.object({
email: z.string().email(),
name: z.string().min(1),
})
)
.mutation(async ({ ctx, input }) => {
const user = await ctx.prisma.user.create({
data: input,
})
return user
}),
})
App Router
// server/routers/_app.ts
import { router } from '../trpc/trpc'
import { userRouter } from './user'
import { postRouter } from './post'
export const appRouter = router({
user: userRouter,
post: postRouter,
})
export type AppRouter = typeof appRouter
Procedures
Query Procedure
// server/routers/user.ts
export const userRouter = router({
getById: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.prisma.user.findUnique({
where: { id: input.id },
include: { posts: true },
})
}),
search: publicProcedure
.input(
z.object({
query: z.string(),
limit: z.number().min(1).max(100).default(10),
})
)
.query(async ({ ctx, input }) => {
return ctx.prisma.user.findMany({
where: {
OR: [
{ name: { contains: input.query } },
{ email: { contains: input.query } },
],
},
take: input.limit,
})
}),
})
Mutation Procedure
// server/routers/user.ts
export const userRouter = router({
create: publicProcedure
.input(
z.object({
email: z.string().email(),
name: z.string().min(1).max(100),
})
)
.mutation(async ({ ctx, input }) => {
const existingUser = await ctx.prisma.user.findUnique({
where: { email: input.email },
})
if (existingUser) {
throw new TRPCError({
code: 'CONFLICT',
message: 'User with this email already exists',
})
}
return ctx.prisma.user.create({
data: input,
})
}),
update: protectedProcedure
.input(
z.object({
id: z.string(),
name: z.string().min(1).optional(),
email: z.string().email().optional(),
})
)
.mutation(async ({ ctx, input }) => {
const { id, ...data } = input
// Ensure user can only update their own profile
if (ctx.user.id !== id) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You can only update your own profile',
})
}
return ctx.prisma.user.update({
where: { id },
data,
})
}),
delete: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
return ctx.prisma.user.delete({
where: { id: input.id },
})
}),
})
Subscription Procedure
// server/routers/post.ts
import { observable } from '@trpc/server/observable'
import { EventEmitter } from 'events'
const postEventEmitter = new EventEmitter()
export const postRouter = router({
onCreate: publicProcedure.subscription(() => {
return observable<Post>((emit) => {
const Post) => {
emit.next(post)
}
postEventEmitter.on('create', onPostCreate)
return () => {
postEventEmitter.off('create', onPostCreate)
}
})
}),
})
Input Validation with Zod
Basic Validation
import { z } from 'zod'
export const userRouter = router({
create: publicProcedure
.input(
z.object({
email: z.string().email('Invalid email address'),
name: z.string().min(1, 'Name is required').max(100),
age: z.number().int().min(0).max(150).optional(),
})
)
.mutation(async ({ ctx, input }) => {
// input is fully typed and validated
return ctx.prisma.user.create({ data: input })
}),
})
Advanced Validation
const createUserSchema = z.object({
email: z.string().email(),
name: z.string().min(1),
password: z.string().min(8).regex(/[A-Z]/, 'Password must contain uppercase'),
confirmPassword: z.string(),
role: z.enum(['USER', 'ADMIN']).default('USER'),
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ['confirmPassword'],
})
export const userRouter = router({
create: publicProcedure
.input(createUserSchema)
.mutation(async ({ ctx, input }) => {
const { confirmPassword, ...userData } = input
return ctx.prisma.user.create({ data: userData })
}),
})
Transform Input
export const userRouter = router({
create: publicProcedure
.input(
z.object({
email: z.string().email().transform((email) => email.toLowerCase()),
name: z.string().transform((name) => name.trim()),
})
)
.mutation(async ({ ctx, input }) => {
// email and name are already transformed
return ctx.prisma.user.create({ data: input })
}),
})
Middleware
Authentication Middleware
// server/trpc/trpc.ts
const isAuthenticated = t.middleware(({ ctx, next }) => {
if (!ctx.user) {
throw new TRPCError({ code: 'UNAUTHORIZED' })
}
return next({
ctx: {
...ctx,
user: ctx.user,
},
})
})
export const protectedProcedure = t.procedure.use(isAuthenticated)
Role-Based Middleware
const isAdmin = t.middleware(({ ctx, next }) => {
if (!ctx.user || ctx.user.role !== 'ADMIN') {
throw new TRPCError({ code: 'FORBIDDEN' })
}
return next({
ctx: {
...ctx,
user: ctx.user,
},
})
})
export const adminProcedure = t.procedure.use(isAuthenticated).use(isAdmin)
Logging Middleware
const logger = t.middleware(async ({ path, type, next }) => {
const start = Date.now()
const result = await next()
const duration = Date.now() - start
console.log(`${type.toUpperCase()} ${path} - ${duration}ms`)
return result
})
export const loggedProcedure = t.procedure.use(logger)
Rate Limiting Middleware
const rateLimiter = new Map<string, number[]>()
const rateLimit = t.middleware(async ({ ctx, next }) => {
const ip = ctx.req.headers['x-forwarded-for'] || ctx.req.socket.remoteAddress
const now = Date.now()
const windowMs = 60000 // 1 minute
const maxRequests = 10
const requests = rateLimiter.get(ip as string) || []
const recentRequests = requests.filter((time) => now - time < windowMs)
if (recentRequests.length >= maxRequests) {
throw new TRPCError({
code: 'TOO_MANY_REQUESTS',
message: 'Rate limit exceeded',
})
}
recentRequests.push(now)
rateLimiter.set(ip as string, recentRequests)
return next()
})
export const rateLimitedProcedure = t.procedure.use(rateLimit)
Next.js API Route Handler
Create 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/trpc/context'
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: '/api/trpc',
req,
router: appRouter,
createContext,
})
export { handler as GET, handler as POST }
React Query Integration
Setup Provider
// app/providers.tsx
'use client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { httpBatchLink } from '@trpc/client'
import { createTRPCReact } from '@trpc/react-query'
import { useState } from 'react'
import type { AppRouter } from '@/server/routers/_app'
export const trpc = createTRPCReact<AppRouter>()
export function TRPCProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient())
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
httpBatchLink({
url: '/api/trpc',
}),
],
})
)
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</trpc.Provider>
)
}
Use in Components
'use client'
import { trpc } from '@/app/providers'
export function UserList() {
const { data, isLoading, error } = trpc.user.getAll.useQuery()
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}</li>
))}
</ul>
)
}
export function CreateUserForm() {
const utils = trpc.useUtils()
const createUser = trpc.user.create.useMutation({
onSuccess: () => {
utils.user.getAll.invalidate()
},
})
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
const formData = new FormData(e.currentTarget)
createUser.mutate({
email: formData.get('email') as string,
name: formData.get('name') as string,
})
}
return (
<form
<input name="email" type="email" required />
<input name="name" type="text" required />
<button type="submit" disabled={createUser.isPending}>
Create User
</button>
</form>
)
}
Error Handling
Custom Error Classes
// server/trpc/errors.ts
import { TRPCError } from '@trpc/server'
export class UserNotFoundError extends TRPCError {
constructor() {
super({
code: 'NOT_FOUND',
message: 'User not found',
})
}
}
export class EmailAlreadyExistsError extends TRPCError {
constructor(email: string) {
super({
code: 'CONFLICT',
message: `User with email ${email} already exists`,
})
}
}
Error Formatting
// server/trpc/trpc.ts
import { TRPCError } from '@trpc/server'
const t = initTRPC.context<Context>().create({
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError:
error.cause instanceof ZodError ? error.cause.flatten() : null,
},
}
},
})
Client-Side Error Handling
'use client'
import { trpc } from '@/app/providers'
export function UserForm() {
const createUser = trpc.user.create.useMutation({
onError: (error) => {
if (error.data?.code === 'CONFLICT') {
alert('Email already exists')
} else if (error.data?.code === 'BAD_REQUEST') {
alert('Invalid input')
} else {
alert('An error occurred')
}
},
})
// ...
}
Type Inference
Infer Router Types
import type { AppRouter } from '@/server/routers/_app'
import type { inferRouterInputs, inferRouterOutputs } from '@trpc/server'
type RouterInputs = inferRouterInputs<AppRouter>
type RouterOutputs = inferRouterOutputs<AppRouter>
type UserOutput = RouterOutputs['user']['getById']
type CreateUserInput = RouterInputs['user']['create']
Use in Components
'use client'
import { trpc } from '@/app/providers'
import type { RouterOutputs } from '@/server/routers/_app'
type User = RouterOutputs['user']['getById']
export function UserCard({ userId }: { userId: string }) {
const { data: user } = trpc.user.getById.useQuery({ id: userId })
if (!user) return null
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
)
}
Best Practices
- Type Safety: Leverage TypeScript and tRPC for end-to-end type safety
- Validation: Always validate inputs with Zod
- Error Handling: Use appropriate error codes and messages
- Middleware: Create reusable middleware for common concerns
- Performance: Use React Query caching and invalidation strategically
- Security: Validate permissions in protected procedures
- Documentation: Document complex procedures and schemas
When to Use
This skill should be used when strict adherence to the defined process is required.
Prerequisites
- Basic understanding of the agent factory context.
- Access to the necessary tools and resources.