From REST to tRPC: The Type-Safe API Revolution
·PkgPulse Team
TL;DR
tRPC gives you full-stack TypeScript type safety with zero code generation — your API types are just your code. tRPC (~2M weekly downloads) became the standard for full-stack TypeScript apps because it eliminates the client/server type drift that causes 30%+ of production bugs. No schema files, no codegen, no OpenAPI spec — just TypeScript. The trade-off: tRPC only works when client and server are TypeScript. For public APIs or multi-language stacks, REST or GraphQL is still the answer.
Key Takeaways
- tRPC: ~2M weekly downloads — end-to-end types, zero codegen, T3 stack default
- Works only with TypeScript — client and server must both be TypeScript
- Zero overhead at runtime — tRPC is a thin wrapper over HTTP fetch
- Validation built-in — Zod input validation with type inference
- GraphQL alternative — similar DX goals but no schema language required
The Problem tRPC Solves
// The REST type drift problem:
// Server (Node.js/TypeScript):
app.get('/api/users/:id', async (req, res) => {
const user = await db.user.findUnique({ where: { id: req.params.id } });
res.json(user);
// Return type: User | null
// But the client doesn't know this...
});
// Client (React/TypeScript):
const response = await fetch('/api/users/123');
const user = await response.json();
// Type: any — you get nothing from TypeScript
// You must manually sync:
type User = { id: string; name: string; email: string }; // Duplicate!
const user: User = await response.json(); // Cast. Hope it matches.
// 3 months later, server adds "createdAt: Date"
// Client still types as User — no error, just a runtime bug
// This is type drift. tRPC eliminates it.
tRPC: End-to-End Types
// server/router.ts — define your API with TypeScript
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
const t = initTRPC.create();
export const appRouter = t.router({
// Query (GET)
user: t.router({
getById: t.procedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
const user = await db.user.findUnique({ where: { id: input.id } });
if (!user) throw new TRPCError({ code: 'NOT_FOUND' });
return user;
// Return type automatically inferred as User
}),
list: t.procedure
.input(z.object({
limit: z.number().min(1).max(100).default(20),
cursor: z.string().optional(),
}))
.query(async ({ input }) => {
const users = await db.user.findMany({
take: input.limit + 1,
cursor: input.cursor ? { id: input.cursor } : undefined,
});
const hasMore = users.length > input.limit;
return {
users: users.slice(0, input.limit),
nextCursor: hasMore ? users[input.limit].id : null,
};
}),
}),
// Mutation (POST/PUT/DELETE)
post: t.router({
create: t.procedure
.input(z.object({
title: z.string().min(1).max(200),
content: z.string(),
published: z.boolean().default(false),
}))
.mutation(async ({ input, ctx }) => {
return db.post.create({
data: { ...input, authorId: ctx.userId },
});
}),
delete: t.procedure
.input(z.object({ id: z.string() }))
.mutation(async ({ input, ctx }) => {
const post = await db.post.findUnique({ where: { id: input.id } });
if (post?.authorId !== ctx.userId) {
throw new TRPCError({ code: 'FORBIDDEN' });
}
return db.post.delete({ where: { id: input.id } });
}),
}),
});
export type AppRouter = typeof appRouter;
// client/trpc.ts — setup
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '@/server/router'; // Import TYPE only
export const trpc = createTRPCReact<AppRouter>();
// Client usage — full type safety, zero runtime type casting
function UserProfile({ userId }: { userId: string }) {
// Type: { id: string; name: string; email: string; createdAt: Date } | undefined
const { data: user, isLoading } = trpc.user.getById.useQuery({ id: userId });
// ^ TypeScript knows EXACTLY what this returns — same type as server
if (isLoading) return <Spinner />;
if (!user) return <NotFound />;
// user.name — autocomplete works. TypeScript error if you access non-existent field.
return <div>{user.name} — {user.email}</div>;
}
function CreatePost() {
const createPost = trpc.post.create.useMutation({
onSuccess: () => utils.post.list.invalidate(),
});
return (
<button
onClick={() => createPost.mutate({
title: 'My Post',
content: 'Hello world',
// published: false ← optional, has default
})}
disabled={createPost.isPending}
>
Create
</button>
);
}
tRPC Middleware (Authentication)
// Protected procedures via middleware
const t = initTRPC.context<Context>().create();
// Context setup (runs per request)
export const createContext = async (opts: CreateNextContextOptions) => {
const session = await getServerSession(opts.req, opts.res, authOptions);
return { session, db };
};
type Context = Awaited<ReturnType<typeof createContext>>;
// Reusable middleware
const enforceAuth = t.middleware(({ ctx, next }) => {
if (!ctx.session?.user) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return next({
ctx: { ...ctx, session: ctx.session }, // Session is non-null after this
});
});
// Procedures
const publicProcedure = t.procedure;
const protectedProcedure = t.procedure.use(enforceAuth);
export const appRouter = t.router({
health: publicProcedure.query(() => 'ok'),
profile: protectedProcedure.query(({ ctx }) => {
// ctx.session.user is guaranteed non-null here
return db.user.findUnique({ where: { id: ctx.session.user.id } });
}),
});
tRPC vs REST vs GraphQL
| Aspect | REST | GraphQL | tRPC |
|---|---|---|---|
| Type safety | Manual / OpenAPI codegen | Schema + codegen | Automatic (TypeScript) |
| Learning curve | Low | High | Medium |
| Public API | ✅ Excellent | ✅ Excellent | ❌ TS-only |
| Multi-language clients | ✅ | ✅ | ❌ |
| Bundle size (client) | ~0 (fetch) | ~30KB (Apollo) | ~15KB |
| Flexibility | High | High | Lower (TS stack required) |
| When to use | Public APIs, REST clients | Complex queries, public | TypeScript monorepo |
| Downloads (2026) | N/A (protocol) | ~3M | ~2M |
When tRPC Makes Sense
✅ Use tRPC when:
- Full TypeScript stack (Next.js + Node.js/Hono)
- You want type safety without codegen maintenance
- Monorepo where server and client share types
- Team is TypeScript-first and values DX
❌ Don't use tRPC when:
- API needs to be consumed by non-TypeScript clients
- You need a public API documentation (OpenAPI/Swagger)
- Multiple services with different tech stacks
- GraphQL's query flexibility is needed (nested resolvers, batching)
- Mobile clients (React Native can use it, but native iOS/Android can't)
tRPC + Zod: The Full-Stack Validation Pattern
// Shared schemas — import on both client and server
// packages/validators/src/user.ts (in a monorepo)
import { z } from 'zod';
export const createUserSchema = z.object({
name: z.string().min(1).max(50),
email: z.string().email(),
role: z.enum(['user', 'admin']).default('user'),
});
export type CreateUserInput = z.infer<typeof createUserSchema>;
// Server — use the shared schema in the procedure
import { createUserSchema } from '@myapp/validators';
export const userRouter = t.router({
create: protectedProcedure
.input(createUserSchema) // Same Zod schema
.mutation(({ input }) => {
// input is fully typed as CreateUserInput
return db.user.create({ data: input });
}),
});
// Client — use the same schema for form validation
import { createUserSchema, CreateUserInput } from '@myapp/validators';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
function CreateUserForm() {
const form = useForm<CreateUserInput>({
resolver: zodResolver(createUserSchema),
});
// Form errors match server validation — single source of truth
}
Compare tRPC and GraphQL package health on PkgPulse.
See the live comparison
View trpc vs. graphql on PkgPulse →