If you have ever maintained a full-stack TypeScript application, you know the pain: you change a field on the backend, forget to update the frontend, and spend twenty minutes debugging a runtime error that TypeScript should have caught at compile time. tRPC exists to make that entire category of bugs disappear. It connects your server and client through TypeScript's own type inference, so every procedure you define on the backend is immediately and automatically available on the frontend with full autocompletion, input validation, and error checking. No schemas to write, no code generation step to run, no drift between what the server expects and what the client sends.
Built for teams that own both sides of the stack, @trpc/server has become a cornerstone of modern full-stack TypeScript development. It powers production applications like Cal.com, sits at the heart of the popular T3 Stack, and has attracted over 39,000 GitHub stars and more than a million weekly npm downloads.
What Makes tRPC Click
tRPC is not just another API framework. Its design philosophy centers on a few ideas that set it apart:
- Zero Code Generation: Unlike GraphQL toolchains that require schema files and codegen pipelines, tRPC uses TypeScript's native type inference. Change a procedure, and the types update instantly.
- Zero Runtime Dependencies: The server package ships with no external dependencies, keeping your bundle lean.
- Request Batching: Multiple simultaneous client requests can be automatically combined into a single HTTP call.
- Subscriptions: Built-in support for real-time data via WebSockets and server-sent events using async generators.
- Framework Agnostic: Adapters exist for Next.js, Express, Fastify, AWS Lambda, and many more platforms.
- Lazy Input Materialization: In v11, inputs are only processed when the procedure actually needs them, improving performance for batched requests.
Setting Up Shop
Install the core packages along with Zod for input validation:
npm install @trpc/server @trpc/client zod
# or
yarn add @trpc/server @trpc/client zod
If you are working with React, you will also want the React Query integration:
npm install @trpc/react-query @tanstack/react-query
Your First Taste of Type Safety
Defining a Router
Everything in tRPC starts with a router. You create procedures that describe what your API can do, and tRPC handles the rest.
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
const t = initTRPC.create();
const appRouter = t.router({
greeting: t.procedure
.input(z.object({ name: z.string() }))
.query(({ input }) => {
return { message: `Hello, ${input.name}!` };
}),
createUser: t.procedure
.input(z.object({
email: z.string().email(),
username: z.string().min(3),
}))
.mutation(({ input }) => {
// input is fully typed: { email: string; username: string }
return { id: crypto.randomUUID(), ...input };
}),
});
export type AppRouter = typeof appRouter;
The AppRouter type export is the secret sauce. The client imports this type (and only this type, never the actual server code) to get complete knowledge of every procedure, its inputs, and its outputs.
Wiring Up the Server
Attach the router to your HTTP server using one of the built-in adapters. Here is a standalone example using the fetch adapter:
import { createHTTPServer } from '@trpc/server/adapters/standalone';
const server = createHTTPServer({
router: appRouter,
});
server.listen(3000);
For Next.js App Router, you would use the fetch adapter inside a route handler. For Express, there is a dedicated middleware. The adapter pattern means tRPC fits into whatever server architecture you already have.
Calling Procedures from the Client
Create a typesafe client and start making calls:
import { createTRPCClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from './server';
const client = createTRPCClient<AppRouter>({
links: [
httpBatchLink({
url: 'http://localhost:3000',
}),
],
});
const result = await client.greeting.query({ name: 'World' });
// result is typed as { message: string }
const user = await client.createUser.mutate({
email: 'dev@example.com',
username: 'typesafe_dev',
});
// user is typed as { id: string; email: string; username: string }
Try passing { name: 42 } to that greeting query. TypeScript will flag the error before you even save the file.
Going Deeper
Middleware and Context
Real applications need authentication, logging, and request context. tRPC handles this through middleware and context:
import { initTRPC, TRPCError } from '@trpc/server';
interface Context {
userId: string | null;
}
const t = initTRPC.context<Context>().create();
const isAuthenticated = t.middleware(({ ctx, next }) => {
if (!ctx.userId) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return next({
ctx: {
userId: ctx.userId,
},
});
});
const protectedProcedure = t.procedure.use(isAuthenticated);
const appRouter = t.router({
publicRoute: t.procedure.query(() => {
return { status: 'open to everyone' };
}),
secretData: protectedProcedure.query(({ ctx }) => {
// ctx.userId is guaranteed to be a string here, not null
return { data: `Secret stuff for user ${ctx.userId}` };
}),
});
The middleware narrows the context type, so inside secretData, TypeScript knows ctx.userId is a string. No runtime checks, no type assertions.
Nested Routers for Organization
As your API grows, you can split it into nested routers to keep things manageable:
const userRouter = t.router({
list: t.procedure.query(async () => {
return await db.user.findMany();
}),
byId: t.procedure
.input(z.object({ id: z.string().uuid() }))
.query(async ({ input }) => {
return await db.user.findUnique({ where: { id: input.id } });
}),
});
const postRouter = t.router({
create: protectedProcedure
.input(z.object({
title: z.string().min(1).max(200),
content: z.string(),
}))
.mutation(async ({ input, ctx }) => {
return await db.post.create({
data: { ...input, authorId: ctx.userId },
});
}),
});
const appRouter = t.router({
user: userRouter,
post: postRouter,
});
On the client side, the nesting is reflected in the API: client.user.byId.query({ id: '...' }) and client.post.create.mutate({ ... }). Every level is fully typed.
Real-Time Data with Subscriptions
tRPC v11 uses async generators for subscriptions, making real-time features straightforward:
const appRouter = t.router({
onNewMessage: t.procedure
.input(z.object({ channelId: z.string() }))
.subscription(async function* ({ input }) {
while (true) {
const message = await waitForNewMessage(input.channelId);
yield message;
}
}),
});
The client receives each yielded value as a typed event, so your real-time data flows through the same type-safe pipeline as everything else.
When tRPC Is the Right Call
tRPC shines when a single team owns both the frontend and backend in TypeScript. It is particularly well suited for internal tools, SaaS applications, and any project where the API is not consumed by third-party clients in other languages. The T3 Stack (Next.js, tRPC, Tailwind, Prisma) has become one of the most popular ways to bootstrap a full-stack TypeScript project, and for good reason: the developer experience is hard to beat.
That said, tRPC is not the right tool for every situation. If you need a public API consumed by clients in Python, Go, or Swift, REST or GraphQL will serve you better. If your frontend and backend teams are completely separate organizations, the implicit contract that tRPC creates may cause more confusion than convenience.
Conclusion
tRPC takes the idea of type safety and stretches it across the entire client-server boundary. By leaning on TypeScript's type inference instead of schemas or code generation, it gives you an API layer that is always in sync, always correct, and never requires a build step just to update your types. With @trpc/server at version 11.10.0 and a vibrant ecosystem of adapters and integrations, it is one of the most compelling tools available for full-stack TypeScript development today.
If your stack is TypeScript from top to bottom, give tRPC a try. Your future self, the one who would have spent an hour tracking down a mismatched field name, will thank you.