The Problem with Traditional APIs
Enter tRPC
How tRPC Compares to REST and GraphQL
| REST | GraphQL | tRPC | |
|---|---|---|---|
| Type safety across client/server | Manual (or via codegen) | Via codegen from schema | Automatic, no codegen |
| Requires a schema definition language | No | Yes (SDL) | No — plain TypeScript |
| Build step to sync types | Yes, if using codegen | Yes | No |
| Works for public/third-party APIs | Yes | Yes | No (TypeScript monorepo only) |
| Learning curve | Low | Moderate–high | Low, if you already know TypeScript |
| Best fit | Public APIs, simple CRUD | Complex, client-driven data needs | Internal full-stack TypeScript apps |
The tRPC Experience
Typescript
// server/routers/user.ts
import { z } from 'zod';
import { publicProcedure, router } from '../trpc';
export const userRouter = router({
// Define a query (GET request)
getUserById: publicProcedure
.input(z.string()) // Input validation
.query(async (opts) => {
const user = await db.user.findById(opts.input);
return user; // TypeScript infers the return type
}),
// Define a mutation (POST/PUT/DELETE-equivalent)
updateUsername: publicProcedure
.input(z.object({ id: z.string(), username: z.string().min(3) }))
.mutation(async (opts) => {
return db.user.update({
where: { id: opts.input.id },
data: { username: opts.input.username },
});
}),
});
Tsx
// client/components/UserProfile.tsx
import { trpc } from '../utils/trpc';
export function UserProfile({ id }: { id: string }) {
// If you forget the 'id', TypeScript will throw an error!
const { data, isLoading } = trpc.user.getUserById.useQuery(id);
if (isLoading) return <div>Loading...</div>;
// TypeScript knows EXACTLY what 'data' looks like based on the backend return type
return <div>{data.name}</div>;
}
Tsx
const updateUsername = trpc.user.updateUsername.useMutation();
function handleSave(id: string, newName: string) {
updateUsername.mutate({ id, username: newName });
}
Setting Up tRPC in a Next.js Project
- Define the context — what's available to every procedure (database client, session, request headers).
- Initialize tRPC and export reusable building blocks (router, publicProcedure, and a protectedProcedure for authenticated routes).
- Compose routers into a single appRouter, which becomes your single source of truth for the entire API surface.
- Expose an HTTP handler at a single Next.js route (typically /api/trpc/[trpc]) that all tRPC calls go through.
- Wire up the client with a tRPC React provider at the root of your app, pointing at that same endpoint.
Typescript
// server/trpc.ts
import { initTRPC } from '@trpc/server';
const t = initTRPC.context<Context>().create();
export const router = t.router;
export const publicProcedure = t.procedure;
export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
if (!ctx.session) throw new Error('UNAUTHORIZED');
return next({ ctx: { ...ctx, session: ctx.session } });
});
Typescript
// 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; // this single type powers the entire frontend
Why Developers Love tRPC
- Instant Feedback. If you change a backend return type from number to string, your frontend IDE immediately shows a red squiggly line wherever that data is consumed — no build step, no waiting for a codegen script to run.
- No GraphQL Boilerplate. GraphQL is powerful, but defining schemas, resolvers, and queries is overkill for many internal applications. tRPC gives you comparable type safety with the simplicity of writing and calling plain functions.
- Built-in Validation. By integrating tightly with Zod, input validation is handled on the server, and the exact validation shape (required fields, string lengths, formats) is reflected automatically in the frontend types.
- Refactoring Confidence. Renaming a field, changing a return shape, or restructuring a router shows up as compiler errors across the entire codebase instantly — refactors that would be terrifying in a loosely-typed REST setup become mechanical.
- Minimal Dependencies. No schema language to learn, no separate query language, no additional build tooling beyond TypeScript itself and the tRPC package.
- Works Naturally with React Query. Caching, background refetching, optimistic updates, and pagination all come from React Query's proven patterns rather than a bespoke client library.
The Catch: Where tRPC Falls Short
- It requires a TypeScript monorepo. tRPC is specifically designed for full-stack TypeScript environments where the frontend and backend share the same codebase, or at minimum the same package for shared types. There's no cross-language contract the way OpenAPI or GraphQL SDL provides.
- It doesn't work for public or third-party APIs. If you need to expose an API for external consumers — a mobile app written in Swift or Kotlin, a partner's Python integration, a public developer API — you still need REST or GraphQL, since those clients can't import your TypeScript types.
- Versioning is less mature. REST and GraphQL both have well-established conventions for versioning a public contract over time. tRPC, built around a single shared type rather than a published schema, doesn't have the same battle-tested versioning story for APIs consumed outside your own deploy.
- Team boundaries can blur. In organizations where frontend and backend are owned by genuinely separate teams (rather than the same full-stack engineers), the tight coupling that makes tRPC convenient can also make it harder to enforce clean API contracts between teams.
tRPC vs. GraphQL vs. REST: Which Should You Choose?
- Choose tRPC if you're building an internal web application, own both the frontend and backend in a single TypeScript codebase, and want maximum developer velocity with zero codegen overhead.
- Choose GraphQL if you have multiple client types with very different data needs (web, iOS, Android) and want clients to request exactly the fields they need, or if you're exposing a flexible API to external partners.
- Choose REST if you need a simple, widely understood, cacheable API — especially for public-facing APIs where broad client compatibility and tooling maturity matter more than developer ergonomics.