tRPC is the end-to-end typesafe API framework that eliminates the gap between your frontend and backend — no code generation, no schemas, just TypeScript. And it's completely free.
Why tRPC?
With REST or GraphQL, your frontend and backend types are disconnected. Change an API response? Your frontend breaks silently at runtime. tRPC makes this impossible:
- End-to-end type safety — change the backend, frontend shows errors immediately
- No code generation — types flow automatically through TypeScript
- No schemas — no GraphQL SDL, no OpenAPI spec
- Autocomplete — full IntelliSense for all API calls
- Subscriptions — real-time via WebSocket
- Batching — multiple calls in one HTTP request
Quick Start
npm install @trpc/server @trpc/client @trpc/react-query zod
Server (Define Your API)
// server/trpc.ts
import { initTRPC, TRPCError } from "@trpc/server";
import { z } from "zod";
const t = initTRPC.create();
const router = t.router;
const publicProcedure = t.procedure;
// Define your API
export const appRouter = router({
// Query (GET)
hello: publicProcedure
.input(z.object({ name: z.string() }))
.query(({ input }) => {
return { greeting: `Hello, ${input.name}!` };
}),
// Query with database
getUser: publicProcedure
.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;
}),
// Mutation (POST/PUT/DELETE)
createUser: publicProcedure
.input(z.object({
name: z.string().min(2),
email: z.string().email(),
}))
.mutation(async ({ input }) => {
return db.user.create({ data: input });
}),
// Nested routers
posts: router({
list: publicProcedure
.input(z.object({ limit: z.number().default(10) }))
.query(async ({ input }) => {
return db.post.findMany({ take: input.limit });
}),
create: publicProcedure
.input(z.object({
title: z.string(),
content: z.string(),
}))
.mutation(async ({ input }) => {
return db.post.create({ data: input });
}),
}),
});
// Export the type — this is the magic!
export type AppRouter = typeof appRouter;
Client (Full Type Safety)
// client/trpc.ts
import { createTRPCClient, httpBatchLink } from "@trpc/client";
import type { AppRouter } from "../server/trpc";
const trpc = createTRPCClient<AppRouter>({
links: [
httpBatchLink({
url: "http://localhost:3000/trpc",
}),
],
});
// Full autocomplete — try it!
const greeting = await trpc.hello.query({ name: "World" });
// greeting.greeting is typed as string
const user = await trpc.getUser.query({ id: "123" });
// user.name, user.email — all typed!
const newUser = await trpc.createUser.mutate({
name: "Alice",
email: "alice@example.com",
// age: 25 ← TypeScript ERROR: 'age' does not exist
});
const posts = await trpc.posts.list.query({ limit: 5 });
// posts is typed as Post[]
React Integration (@trpc/react-query)
// utils/trpc.ts
import { createTRPCReact } from "@trpc/react-query";
import type { AppRouter } from "../server/trpc";
export const trpc = createTRPCReact<AppRouter>();
// components/UserProfile.tsx
import { trpc } from "../utils/trpc";
function UserProfile({ userId }: { userId: string }) {
const { data: user, isLoading } = trpc.getUser.useQuery({ id: userId });
const updateUser = trpc.updateUser.useMutation({
onSuccess: () => {
// Invalidate cache
utils.getUser.invalidate({ id: userId });
},
});
if (isLoading) return <div>Loading...</div>;
if (!user) return <div>User not found</div>;
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
<button onClick={() => updateUser.mutate({ id: userId, name: "New Name" })}>
Update Name
</button>
</div>
);
}
Authentication Middleware
const t = initTRPC.context<{ user?: { id: string; role: string } }>().create();
const isAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.user) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next({ ctx: { user: ctx.user } });
});
const isAdmin = t.middleware(({ ctx, next }) => {
if (!ctx.user || ctx.user.role !== "admin") {
throw new TRPCError({ code: "FORBIDDEN" });
}
return next({ ctx: { user: ctx.user } });
});
const protectedProcedure = t.procedure.use(isAuthed);
const adminProcedure = t.procedure.use(isAdmin);
export const appRouter = router({
// Public
hello: publicProcedure.query(() => "Hello!"),
// Requires login
myProfile: protectedProcedure.query(({ ctx }) => {
return db.user.findUnique({ where: { id: ctx.user.id } });
}),
// Requires admin
deleteUser: adminProcedure
.input(z.object({ userId: z.string() }))
.mutation(({ input }) => {
return db.user.delete({ where: { id: input.userId } });
}),
});
tRPC vs REST vs GraphQL
| Feature | tRPC | REST | GraphQL |
|---|---|---|---|
| Type safety | End-to-end | Manual (OpenAPI) | Code generation |
| Schema | None needed | OpenAPI spec | SDL required |
| Overfetching | No (TypeScript) | Common | No (queries) |
| Learning curve | Low (just TS) | Low | Medium |
| Batching | Built-in | Manual | Built-in |
| File uploads | Via formData | Native | Complex |
| Caching | React Query | HTTP cache | Apollo cache |
| Best for | TS fullstack | Public APIs | Multi-client |
Need to scrape data from any website and get it in structured JSON? Check out my web scraping tools on Apify — no coding required, results in minutes.
Have a custom data extraction project? Email me at spinov001@gmail.com — I build tailored scraping solutions for businesses.
Top comments (0)