Building Type-Safe APIs with tRPC and Zod
End-to-end type safety from database to frontend — no code generation, no schema files, just TypeScript.
In a traditional REST API, the contract between frontend and backend lives in documentation (OpenAPI spec, README, Postman collections) that's always slightly out of date. GraphQL improves this with a schema, but requires code generation to get TypeScript types. tRPC takes a radically different approach: the TypeScript types ARE the API contract. Change a backend function's return type, and the frontend gets a type error immediately — no code generation, no schema files, no API documentation to maintain.
How tRPC Works
tRPC creates a direct type-level connection between your backend router and frontend client. You define procedures (queries and mutations) on the server with Zod input validation, and the client automatically infers the input and output types. There's no HTTP layer to think about — it feels like calling a local function.
import { z } from 'zod';
import { router, publicProcedure, protectedProcedure } from '../trpc';
export const userRouter = router({
getById: publicProcedure
.input(z.object({ id: z.string().uuid() }))
.query(async ({ input, ctx }) => {
const user = await ctx.db.user.findUnique({ where: { id: input.id } });
if (!user) throw new TRPCError({ code: 'NOT_FOUND' });
return user; // Return type is automatically inferred
}),
updateProfile: 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.userId },
data: input,
});
}),
});Frontend Integration
'use client';
import { trpc } from '@/lib/trpc';
export default function ProfilePage({ params }: { params: { id: string } }) {
// Full type inference — IDE shows return type, auto-completes fields
const { data: user, isLoading } = trpc.user.getById.useQuery({ id: params.id });
const updateProfile = trpc.user.updateProfile.useMutation({
onSuccess: () => { /* invalidate cache, show toast */ },
});
if (isLoading) return <Skeleton />;
if (!user) return <NotFound />;
return (
<div>
<h1>{user.name}</h1> {/* TypeScript knows this is a string */}
<p>{user.bio}</p> {/* TypeScript knows this is string | null */}
</div>
);
}When to Use tRPC vs REST vs GraphQL
- Use tRPC when: Your frontend and backend are in the same TypeScript monorepo. Maximum type safety, zero overhead.
- Use REST when: You have multiple non-TypeScript clients, need HTTP caching, or are building a public API.
- Use GraphQL when: You have diverse clients with different data needs and can't use tRPC (non-TypeScript clients).
- Hybrid: tRPC for your own frontend, REST/GraphQL for third-party integrations. They coexist naturally.
tRPC works beautifully with Next.js App Router. Use tRPC for client-side data fetching (mutations, real-time updates) and React Server Components for initial page data. They complement each other perfectly.
tRPC eliminates the entire class of bugs caused by frontend-backend type mismatches. No more 'the API changed and nobody told the frontend team.' If it compiles, it works. For TypeScript monorepos, it's the most productive API approach we've used.
Priya Patel
Senior Backend Engineer