tRPC Explained: Building End-to-End Type-Safe APIs with TypeScript
Why traditional API development can become frustrating, how tRPC works internally, and when you should use it.
1. The Problem: When Your Frontend and Backend Stop Speaking the Same Language
Imagine you’re building a full-stack application.
Your frontend is written in React and TypeScript, while your backend uses Node.js.
Everything seems straightforward. You create an API endpoint, fetch the data from React, and display it
For example, your backend exposes:
GET /api/users/1And returns:
{
"id": 1,
"name": "Alex",
"email": "alex@example.com"
}On your frontend, you define an interface describing the expected response:
interface User {
id: number,
name: string,
email: string
}You fetch the data, display the user’s name, and everything works.
But now imagine another developer changes the backend response.
Instead of returning name, the backend now returns fullName.
{
"id": 1,
"fullName": "Alex Johnson",
"email": "alex@example.com"
}Your frontend still expects:
console.log(user.name);Here’s the problem.
TypeScript might not report any error.
Your frontend interface still says that name exists. TypeScript trusts that declaration, even thought the actual backend response has changed.
At runtime, user.name becomes undefined.
And suddenly, a small backend change breaks your frontend.
Why does this happen?
In many traditional REST-based applications, the frontend and backend maintain separate definitions of the same data.
The backend defines the actual response, while the frontend defines what it expects.
As your application grows, you might have dozens of endpoints, hundreds of components, and multiple developers modifying API contracts.
Keeping everything synchronized becomes increasingly difficult.
Of course, REST APIs can also achieve type safety through OpenAPI, code generation, and shared schemas. But these approaches often introduce additional tooling or synchronization steps.
This raises an interesting question:
What if your frontend could automatically understand your backend’s types without manually redefining them?
What if changing a backend response immediately produced a TypeScript error in every frontend component affected by that change?
This is the problem tRPC aims to solve.
2. What Is tRPC?
tRPC is a TypeScript-first framework that allows developers to build end-to-end type-safe APIs without manually maintaining separate API type definitions.
The name comes from TypeScript Remote Procedure Call.
But what exactly does that mean?
First, understand Remote Procedure Calls (RPC)
Normally, when your frontend needs information from the backend, it sends an HTTP request to an API endpoint.
For example:
const response = await fetch("/api/users/1");
const user = await response.json();You need to know the endpoint URL, HTTP method, expected request parameters, and response structure.
With the RPC programming model, you think in terms of calling a named function or procedure that executes on another system.
For example, using a tRPC client:
const user = await trpc.user.getById.query({
id: 1,
});This looks like a regular TypeScript method call.
However, the procedure actually executes on the server.
Important: tRPC does not eliminate HTTP or network communication.
Behind the scenes, the tRPC client serializes your input, sends an HTTP request to the server, waits for the procedure to execute, and processes the response.
The difference is the developer experience.
Instead of manually maintaining endpoint contracts, tRPC allows the client to infer types from the backend router.
What is end-to-end type safety?
Suppose your backend defines a procedure that accepts a numeric user ID.
.input(z.object({
id: z.number(),
}))Your frontend can infer that requirement.
This works:
trpc.user.getById.query({ id: 1 });But this produces a TypeScript error:
trpc.user.getById.query({ id: "1" });Why?
Because the backend expects a number, but the frontend is attempting to send a string.
The TypeScript compiler can catch this mistake before the application runs.
And this applies to both procedure inputs and inferred outputs.
That’s the core idea behind tRPC: define your API contract on the server and infer it on the client.
3. How Does tRPC Work Internally?
To understand tRPC, let’s look at its main building blocks.
3.1 Routers
A router organizes related backend procedures.
For example, an application might have:
appRouter
├── userRouter
│ ├── getById
│ ├── getAll
│ └── create
│
├── productRouter
│ ├── getAll
│ └── create
│
└── orderRouter
├── getById
└── createThink of routers as a way to group related backend functionality.
3.2 Procedures
Procedures are the actual operations your backend exposes.
In tRPC, the main procedure types are:
- Queries: Typically used to read data.
- Mutations: Typically used to create, update, or delete data.
- Subscriptions: Used for receiving ongoing updates when configured with a supported transport.
For example:
const userRouter = router({
getById: publicProcedure
.input(z.object({ id: z.number() }))
.query(({ input }) => {
return {
id: input.id,
name: "Alex",
};
}),
});Here, getById is a query procedure that receives an ID and returns user information.
3.3 Input validation
TypeScript provides compile-time checking, but it cannot guarantee that incoming network data is valid.
This is where runtime validation comes in.
tRPC integrates with schema validation libraries such as Zod.
.input(
z.object({
id: z.number().int().positive(),
})
)If a request contains an invalid ID, validation rejects it before the procedure’s resolver executes.
This distinction matters:
TypeScript checks types during development. Zod validates actual data at runtime.
3.4 Context
Context provides information and resources that procedures need during execution.
For example:
const createContext = async () => ({
db: database,
user: currentUser,
});Procedures can access context through ctx.
.query(async ({ ctx, input }) => {
return ctx.db.user.findUnique({
where: { id: input.id },
});
})Context is commonly used for database connections, authentication information, and request-specific services.
3.5 Middleware
Middleware executes logic around a procedure.
For example, before allowing someone to update a user profile, you might want to verify that they are authenticated.
const protectedProcedure = publicProcedure.use(
async ({ ctx, next }) => {
if (!ctx.user) {
throw new TRPCError({
code: "UNAUTHORIZED",
});
} return next({
ctx: {
user: ctx.user,
},
});
}
);Middleware helps implement reusable authentication, authorization, logging, and other cross-cutting functionality.
3.6 Client-side type inference
This is where everything comes together.
The backend exports its router type:
export type AppRouter = typeof appRouter;The frontend imports that type and uses it to configure a typed tRPC client.
TypeScript can now understand which procedures exist, which inputs they accept, and what outputs they return.
The client does not need a separately handwritten interface for each procedure.
What happens when you make a tRPC call?
Let’s follow a request from the frontend to the database.
- A React component calls
trpc.user.getById.query({ id: 1 }). - The tRPC client serializes the input and sends an HTTP request.
- The server’s tRPC adapter receives the request and routes it to the matching procedure.
- Context is created, middleware executes, and input validation checks the request.
- The procedure runs its business logic and queries the database.
- The server serializes the result and returns it over HTTP.
- The client processes the response and provides the inferred result type to the frontend.
The network still transfers serialized data. TypeScript’s inferred types exist during development and compilation rather than being transmitted as TypeScript types.
4. REST vs tRPC: Let’s Build the Same Feature
Now that we understand the basics, let’s compare both approaches using the same example.
Get Neelammore’s stories in your inbox
Join Medium for free to get updates from this writer.
Our goal is simple:
Fetch a user by ID and display their name.
We’ll first implement it using a manually typed REST approach and then implement the same functionality using tRPC.
Approach 1: Traditional REST API
Let’s create an endpoint using Express.
Backend — Express
import express from "express";const app = express();app.get("/api/users/:id", (req, res) => {
const id = Number(req.params.id); res.json({
id,
name: "Alex Johnson",
email: "alex@example.com",
});
});app.listen(3001);
The backend exposes an HTTP GET endpoint.
Now let’s consume it from React.
Frontend — React and TypeScript
interface User {
id: number;
name: string;
email: string;
}async function getUser(id: number): Promise<User> {
const response = await fetch(
`http://localhost:3001/api/users/${id}`
); if (!response.ok) {
throw new Error("Failed to fetch user");
} return response.json();
}
Notice that we manually created the User interface.
The frontend assumes the backend returns data matching this interface.
But response.json() does not validate that assumption.
What happens when the backend changes?
Suppose the backend developer renames name to fullName.
res.json({
id,
fullName: "Alex Johnson",
email: "alex@example.com",
});The frontend still has:
interface User {
id: number;
name: string;
email: string;
}And still accesses:
user.name;The frontend can compile successfully because TypeScript has no automatic knowledge that the server response changed.
The error appears only when the application runs.
This is an example of API contract drift.
Approach 2: The Same Feature Using tRPC
Now let’s implement the same functionality using tRPC.
For simplicity, assume we’re using tRPC v11, TypeScript, Zod, and a compatible HTTP server adapter.
Step 1: Initialize tRPC
// server/trpc.ts
import { initTRPC } from "@trpc/server";const t = initTRPC.create();export const router = t.router;
export const publicProcedure = t.procedure;
This creates the foundational utilities needed to define routers and procedures.
Step 2: Create the user router
// server/routers/user.ts
import { z } from "zod";
import { router, publicProcedure } from "../trpc";export const userRouter = router({
getById: publicProcedure
.input(
z.object({
id: z.number(),
})
)
.query(({ input }) => {
return {
id: input.id,
name: "Alex Johnson",
email: "alex@example.com",
};
}),
});Notice three important things:
- We defined a procedure called
getById. - Zod validates the input.
- TypeScript infers the return type directly from the resolver.
We did not manually write a separate response interface.
Step 3: Create the main application router
// server/root.ts
import { router } from "./trpc";
import { userRouter } from "./routers/user";export const appRouter = router({
user: userRouter,
});export type AppRouter = typeof appRouter;
The AppRouter type represents the structure of our API.
This is the type our frontend will use.
Step 4: Configure the tRPC client
// client/trpc.ts
import { createTRPCClient, httpBatchLink } from "@trpc/client";
import type { AppRouter } from "../server/root";export const trpc = createTRPCClient<AppRouter>({
links: [
httpBatchLink({
url: "http://localhost:3001/trpc",
}),
],
});This example assumes the backend exposes its tRPC router at /trpc using a compatible adapter.
Notice that the frontend imports only the AppRouter type, not the backend implementation.
Step 5: Call the backend procedure
const user = await trpc.user.getById.query({
id: 1,
});console.log(user.name);That’s it.
The frontend automatically understands that getById expects a numeric ID and returns an object containing id, name, and email.
Now let’s change the backend again
Suppose we rename name to fullName.
.query(({ input }) => {
return {
id: input.id,
fullName: "Alex Johnson",
email: "alex@example.com",
};
})What happens when the frontend still accesses:
console.log(user.name);TypeScript reports an error:
Property 'name' does not exist on type
'{ id: number; fullName: string; email: string; }'.Instead of discovering the mismatch at runtime, the developer can identify it during type checking.
The correct frontend code becomes:
console.log(user.fullName);This is one of tRPC’s biggest advantages.
A change in the backend contract becomes visible to the frontend through TypeScript’s type system.
One important qualification: this works when the frontend uses the updated router type. If independently deployed frontend and backend versions become incompatible, compile-time inference alone cannot prevent runtime version mismatches.
Also, tRPC doesn’t replace the need to start an HTTP server, configure an adapter, handle errors, or deploy your backend.
It simplifies how your TypeScript application defines and consumes API contracts.
6. When Should You Use tRPC?
So, should you replace every REST API with tRPC?
Not necessarily.
The right choice depends on your application’s architecture, clients, and development requirements.
tRPC is a great fit when:
You’re building a full-stack TypeScript application.
For example, a Next.js application with a TypeScript backend can benefit significantly from inferred API contracts.
You have a monorepo or shared TypeScript packages.
When frontend and backend can easily share the router type, tRPC provides a particularly smooth development workflow.
You’re building an internal dashboard or SaaS application.
Applications with many forms, tables, filters, and mutations can benefit from autocomplete and consistent procedure types.
Your team controls both frontend and backend.
When the same team develops both sides, coordinating changes through TypeScript is often straightforward.
Consider REST when:
You’re building a public API consumed by different programming languages, need a widely supported HTTP interface, or want to expose an OpenAPI-based contract.
REST also remains a strong choice for applications that already have established API conventions and tooling.
Consider GraphQL when:
Your application benefits from client-selected response fields, complex relationships between resources, or a schema-driven API used by multiple clients with different data requirements.
GraphQL introduces its own complexity, so it isn’t automatically better than tRPC or REST.
A practical decision rule
If your frontend and backend are both TypeScript, your team controls both sides, and developer productivity is a priority, tRPC is worth considering.
If your API must support many independent clients across different languages, REST or GraphQL may be more appropriate.
The goal isn’t to choose the newest technology.
It’s to choose the architecture that best matches your requirements.
7. Conclusion: What tRPC Actually Solves
When I first explored tRPC, the most interesting part wasn’t the syntax.
It was the idea that the frontend and backend could share a single source of truth for their API contract without manually duplicating every request and response type.
Traditionally, developers spend time ensuring that their frontend expectations match their backend implementation.
tRPC changes that workflow by allowing TypeScript to infer procedure types directly from the server.
This leads to better autocompletion, easier refactoring, and earlier detection of type mismatches.
But tRPC isn’t a replacement for everything.
It still relies on network communication, still needs runtime validation, and isn’t always the best fit for public or language-independent APIs.
The biggest lesson is that API development isn’t just about sending data between two systems. It’s also about keeping both sides in agreement about what that data should look like.
For full-stack TypeScript applications, tRPC provides an elegant way to maintain that agreement.
And sometimes, the best developer experience comes not from writing more code, but from removing the need to write the same contract twice.
Have you used tRPC in a real-world project, or do you prefer REST or GraphQL? I’d love to hear how you approach type safety in your full-stack applications.




