tRPC — End-to-End Type-Safe APIs Without REST or GraphQL
How tRPC eliminates the API layer by sharing types between your TypeScript backend and frontend — no code generation, no schemas, no REST endpoints.
Building a REST API means defining endpoints on the server, writing fetch calls on the client, and hoping the request/response shapes match. Add TypeScript and you're maintaining types in two places — server types and client types — with nothing connecting them. GraphQL solves the type problem but adds a schema language, a code generation step, and a runtime resolver layer.
tRPC removes the API layer entirely. You write functions on the server. You call those functions from the client. TypeScript infers the types across the boundary. Change a return type on the server and your client gets a type error immediately. No REST. No GraphQL. No code generation. No API contract to maintain.
The catch: both client and server must be TypeScript. If that's already your stack, tRPC is the shortest path from database query to rendered component.
How It Works
bun add @trpc/server @trpc/client @trpc/react-query @tanstack/react-query zod
Server
// server/trpc.ts — initialize tRPC
import { initTRPC, TRPCError } from "@trpc/server";
import { z } from "zod";
const t = initTRPC.create();
export const router = t.router;
export const publicProcedure = t.procedure;
export const protectedProcedure = t.procedure.use(async ({ ctx, next }) => {
if (!ctx.user) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next({ ctx: { ...ctx, user: ctx.user } });
});
// server/routers/user.ts
import { z } from "zod";
import { router, publicProcedure, protectedProcedure } from "../trpc";
import { db } from "../db";
export const userRouter = router({
// Query — read data
getById: publicProcedure
.input(z.object({ id: z.number() }))
.query(async ({ input }) => {
const user = await db.query.users.findFirst({
where: eq(users.id, input.id),
});
if (!user) throw new TRPCError({ code: "NOT_FOUND" });
return user;
}),
// Query with no input
list: publicProcedure.query(async () => {
return db.query.users.findMany({
orderBy: [desc(users.createdAt)],
limit: 50,
});
}),
// Mutation — write data
create: protectedProcedure
.input(z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
}))
.mutation(async ({ input }) => {
const [user] = await db
.insert(users)
.values(input)
.returning();
return user;
}),
// Mutation with complex validation
updateProfile: protectedProcedure
.input(z.object({
name: z.string().min(1).max(100).optional(),
bio: z.string().max(500).optional(),
avatarUrl: z.string().url().optional(),
}))
.mutation(async ({ input, ctx }) => {
const [updated] = await db
.update(users)
.set(input)
.where(eq(users.id, ctx.user.id))
.returning();
return updated;
}),
});
// server/routers/index.ts — combine routers
import { router } from "../trpc";
import { userRouter } from "./user";
import { postRouter } from "./post";
export const appRouter = router({
user: userRouter,
post: postRouter,
});
// Export the type — this is what the client imports
export type AppRouter = typeof appRouter;
Client
// client/trpc.ts
import { createTRPCReact } from "@trpc/react-query";
import type { AppRouter } from "../server/routers";
export const trpc = createTRPCReact<AppRouter>();
// client/app.tsx — provider setup
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { httpBatchLink } from "@trpc/client";
import { trpc } from "./trpc";
const queryClient = new QueryClient();
const trpcClient = trpc.createClient({
links: [
httpBatchLink({
url: "http://localhost:3000/trpc",
}),
],
});
function App() {
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
<YourApp />
</QueryClientProvider>
</trpc.Provider>
);
}
Using It in Components
// This is where the magic happens
import { trpc } from "../trpc";
function UserProfile({ userId }: { userId: number }) {
// Full autocomplete — TypeScript knows the input shape, output shape, and error types
const { data: user, isLoading } = trpc.user.getById.useQuery({ id: userId });
// Hover over user — TypeScript shows the exact return type from your server function
// Change the server return type and this component gets a type error immediately
if (isLoading) return <Spinner />;
if (!user) return <NotFound />;
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
);
}
function UserList() {
const { data: users } = trpc.user.list.useQuery();
const utils = trpc.useUtils();
const createUser = trpc.user.create.useMutation({
onSuccess: () => {
// Invalidate the list query — triggers refetch
utils.user.list.invalidate();
},
});
return (
<div>
{users?.map((user) => (
<div key={user.id}>{user.name} — {user.email}</div>
))}
<button onClick={() => createUser.mutate({ name: "New User", email: "new@example.com" })}>
Add User
</button>
</div>
);
}
No fetch(). No URL strings. No request/response type definitions. No API client SDK. Just function calls with full type safety.
Middleware
tRPC middleware lets you add authentication, logging, rate limiting, and other cross-cutting concerns.
import { initTRPC, TRPCError } from "@trpc/server";
const t = initTRPC.context<{ user?: { id: number; role: string } }>().create();
// Logging middleware
const loggerMiddleware = t.middleware(async ({ path, type, next }) => {
const start = Date.now();
const result = await next();
const duration = Date.now() - start;
console.log(${type} ${path} — ${duration}ms);
return result;
});
// Auth middleware
const authMiddleware = t.middleware(async ({ ctx, next }) => {
if (!ctx.user) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Not logged in" });
}
return next({ ctx: { ...ctx, user: ctx.user } });
});
// Admin middleware
const adminMiddleware = t.middleware(async ({ ctx, next }) => {
if (!ctx.user || ctx.user.role !== "admin") {
throw new TRPCError({ code: "FORBIDDEN", message: "Admin access required" });
}
return next({ ctx });
});
export const publicProcedure = t.procedure.use(loggerMiddleware);
export const protectedProcedure = t.procedure.use(loggerMiddleware).use(authMiddleware);
export const adminProcedure = t.procedure.use(loggerMiddleware).use(authMiddleware).use(adminMiddleware);
Subscriptions (Real-time)
tRPC supports WebSocket subscriptions for real-time data:
// Server
import { observable } from "@trpc/server/observable";
export const chatRouter = router({
onMessage: publicProcedure
.input(z.object({ roomId: z.string() }))
.subscription(({ input }) => {
return observable<{ text: string; author: string }>((emit) => {
const handler = (message: ChatMessage) => {
if (message.roomId === input.roomId) {
emit.next({ text: message.text, author: message.author });
}
};
eventEmitter.on("message", handler);
return () => eventEmitter.off("message", handler);
});
}),
});
// Client
function ChatRoom({ roomId }: { roomId: string }) {
const [messages, setMessages] = useState<Message[]>([]);
trpc.chat.onMessage.useSubscription(
{ roomId },
{
onData: (message) => {
setMessages((prev) => [...prev, message]);
},
}
);
return (
<div>
{messages.map((msg, i) => (
<p key={i}><strong>{msg.author}:</strong> {msg.text}</p>
))}
</div>
);
}
tRPC vs REST vs GraphQL
| Aspect | REST | GraphQL | tRPC |
|---|---|---|---|
| Type safety | Manual (OpenAPI + codegen) | Schema + codegen | Automatic (inferred) |
| Contract | URL paths + JSON | Schema language | TypeScript types |
| Code generation | Optional but recommended | Required for TS types | None needed |
| Overfetching | Common problem | Solved by design | Not applicable |
| Learning curve | Low | Medium-high | Low (if you know TS) |
| Client flexibility | Multiple languages | Multiple languages | TypeScript only |
| Bundle size | Minimal | ~20-50KB (client) | ~10KB |
| Tooling | Swagger, Postman | GraphiQL, Apollo Studio | tRPC Panel |
| Caching | HTTP caching (CDN) | Complex (normalized cache) | TanStack Query |
- Both client and server are TypeScript
- It's a monorepo or you control both ends
- You want maximum type safety with minimum ceremony
- The team is tired of maintaining API contracts
- Multiple clients in different languages need the API
- You need HTTP caching (CDN, browser cache)
- The API is public-facing
- Simple CRUD without complex data relationships
- Multiple clients need different data shapes from the same API
- You have deeply nested, interconnected data
- The API serves mobile clients with bandwidth constraints
Integration with Next.js
tRPC works particularly well with Next.js App Router:
// app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { appRouter } from "@/server/routers";
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: "/api/trpc",
req,
router: appRouter,
createContext: () => ({}),
});
export { handler as GET, handler as POST };
tRPC is one of those tools that feels like it shouldn't work — sharing types between processes sounds fragile. But the implementation is solid, the TypeScript integration is seamless, and the development speed improvement is dramatic. If your stack is all TypeScript, try it on your next project. More full-stack TypeScript guides at CodeUp.