DEV Community

Atlas Whoff
Atlas Whoff

Posted on

tRPC End-to-End Type Safety: Build APIs That Can't Break at Runtime

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
Enter fullscreen mode Exit fullscreen mode

With tRPC:

// Same type, automatically
const user = await trpc.user.get.query({ id: '1' })  // type: User
user.naem  // TypeScript error immediately
Enter fullscreen mode Exit fullscreen mode

Setup

npm install @trpc/server @trpc/client @trpc/react-query @trpc/next zod
Enter fullscreen mode Exit fullscreen mode
// 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 } })
})
Enter fullscreen mode Exit fullscreen mode

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 }
    }),
})
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

Client Usage

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

export const trpc = createTRPCReact<AppRouter>()
Enter fullscreen mode Exit fullscreen mode
// 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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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 */ })
Enter fullscreen mode Exit fullscreen mode

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 />
Enter fullscreen mode Exit fullscreen mode

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)