Even though it feels like a shiny new frontend framework crashes onto the scene every other week (each one being boldly paraded as the new king of the web), backend development moves at a pace that actually lets you blink without missing an entire revolution.
Framework wars aside, when it comes to modern APIs, most fall into one of four camps.
First, there’s REST, the grizzled, battle-hardened veteran of the web, so old and wise it’s basically Yoda with an HTTP spec.
Then there’s GraphQL, Meta’s golden child, rolling in like a rockstar promising to fix all of REST’s so-called problems, only to leave developers wondering if they just swapped one set of headaches for another.
And then we have Google’s gRPC and its scrappy step-cousin, tRPC, both determined to make HTTP requests feel like good old-fashioned function calls, so that you can pretend the internet is one giant local codebase.
Each of these paradigms have a time and a place, but one thing I’ve been noticing is how little attention tRPC has been getting compared to the others, especially when it’s a perfect fit for many modern projects.
Like with any modern tool, tRPC brings a whole batch of promises with it.
It eliminates a lot of API boilerplate while keeping everything type-safe.
That means no more endless Swagger docs, no more manually writing TypeScript interfaces for your backend requests and responses, and best of all, no more axios.get(‘/some-endpoint’) as unknown as SomeType.
So, in this piece, let’s take a deep dive into tRPC, and hopefully by the end of it, you’ll have a cool new migration effort to pitch to your manager (I can see him sweating already).
What is tRPC?
At its core, tRPC (TypeScript Remote Procedure Call) is a framework for building fully type-safe APIs without writing a single line of OpenAPI/GraphQL schema.Instead of manually defining request and response types,
tRPC leverages TypeScript inference to keep your frontend and backend in sync automatically.How does it work?
Let’s say we have a User Service that exposes a getUser endpoint, and now we need an Order Service that fetches user order history, but it needs to first retrieve user details from the User Service.
Here’s what the User Service would look like:
import { initTRPC } from "@trpc/server";
import { z } from "zod";
const t = initTRPC.create();
const userRouter = t.router({
getUser: t.procedure
.input(z.object({ id: z.string() }))
.query(({ input }) => {
return { id: input.id, name: "John Doe" }; // Fake user data
}),
});
export type UserRouter = typeof userRouter;
export { userRouter };And the Order Service can easily call the User Service:
import { initTRPC } from "@trpc/server";
import { createTRPCProxyClient, httpBatchLink } from "@trpc/client";
import { z } from "zod";
import type { UserRouter } from "../user-service";
const t = initTRPC.create();
// Create a tRPC client to call the User Service
const userService = createTRPCProxyClient<UserRouter>({
links: [httpBatchLink({ url: "http://localhost:4000/trpc" })],
});
const orderRouter = t.router({
getUserOrders: t.procedure
.input(z.object({ userId: z.string() }))
.query(async ({ input }) => {
// Call the User Service first
const user = await userService.getUser.query({ id: input.userId });
return {
user,
orders: [
{ id: "order1", item: "Laptop", price: 1200 },
{ id: "order2", item: "Headphones", price: 200 },
], // Fake orders
};
}),
});And your frontend can use your services like so:
// src/utils/trpc.ts
import { createTRPCReact } from "@trpc/react-query";
import { httpBatchLink } from "@trpc/client";
import type { OrderRouter } from "../../order-service";
export const trpc = createTRPCReact<OrderRouter>();
export const trpcClient = trpc.createClient({
links: [httpBatchLink({ url: "http://localhost:5000/trpc" })],
});// src/index.tsx
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { trpc, trpcClient } from "./utils/trpc";
const queryClient = new QueryClient();
ReactDOM.createRoot(document.getElementById("root")!).render(
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</trpc.Provider>
);// src/App.tsx
import { trpc } from "./utils/trpc";
export default function App() {
const { data, isLoading, error } = trpc.getUserOrders.useQuery({ userId: "1234" });
return (
// ...
)
}Note
This example uses
TanStack Query, but you can use it in a variety of other methods in the frontend.
That’s it. No endpoint juggling. No schema stitching. No tears.
As you can see, the magic comes out of the exported type of the router in each service.
All you need to do is setup a tRPC client with a base url and that router interface and you’re good to go!
No need to manually duplicate and re-write all the endpoints, the requests and responses!
With tRPC, your backend procedures and frontend calls live in perfect harmony (like a dev team that actually agrees on tabs vs spaces).
You write your router once, export its type, and boom: the client knows exactly how to talk to the server.
No “oh, the docs are out of date again.”
No “wait, what does this endpoint return these days?”
Just TypeScript doing what it does best: being type-safe.
But besides the easiness of use, there are a bunch of hidden benefits that might alude you, that are worth noticing:
Break stuff without breaking a sweat
Making breaking changes in an API’s request/response can be a pretty dangerous thing in some systems.Whether if you have several microservices that consume each other’s API, or have a client or two being fed data through some backend service, any kind of breaking change can cause all sorts of headaches, ranging from “this feature doesn’t work anymore” to “half the app silently fails in production and no one notices until someone tweets about it.”
However, tRPC makes this issue significantly more manageable.
If you want to break getUsersRequest or getUsersResponse, you’ll be generating a new UserRouter, and so every consumer of that interface will shout build errors at you until you accommodate the new changes.
In other words, tRPC removes the fear of change.
Instead of praying everything still works, TypeScript becomes your relentless QA tester.
Who’s using this API?
Another overlooked feature: introspectability.Since every consumer of your API is statically typed and uses a shared interface, you can trace exactly who’s using what endpoint just by letting your IDE do the legwork.
Need to refactor getUserOrders? Just “Find References” and boom—every place in your codebase (frontend, backend, other services, grandma’s Raspberry Pi) that depends on that route is now highlighted in bright, anxiety-inducing yellow.
Compare that to REST, where a random string like /api/v3/user-orders could be buried in 10 different codebases, Slack messages, and Postman collections last updated in 2018.
Caveats
Alright, time for a reality check.tRPC is amazing, but it’s not magical fairy dust, and there are some downsides.It’s TypeScript or bust.
If your team is still rocking JavaScript (God have mercy on your soul) or mixing in other languages, tRPC will just stare at you with disappointment.
It’s unapologetically TypeScript-native and doesn’t offer much if you’re not in TypeScript land.
It assumes you have a monorepo or shared types.
If your frontend and backend live in different galaxies (or GitHub orgs), sharing types becomes an awkward dance involving npm packages, private npm registries, and a lot of crying.
No protocol flexibility.tRPC is HTTP-first and doesn’t support things like gRPC or GraphQL, so if you want to impress anyone by opting out of the good old HTTP protocol, you’re out of luck.
It’s not great for public APIs.
Want to expose an external API to third-party developers? tRPC’s lack of schema generation and standard documentation (like OpenAPI) makes it less than ideal. Unless your external partners are cool with npm install your-entire-monorepo, maybe stick with REST or GraphQL here.
Wait, what about ts-rest?
ts-rest is the rising TypeScript darling that also promises to end the age-old ritual of duplicating request and response types between backend and frontend.Like
tRPC, it wants to give you type safety and a solid DX, but despite both of them chasing the same goal, they’re taking very different roads to get there.While tRPC thrives in monorepos where everyone shares types, ts-rest is more about explicit API contracts. You write a single source-of-truth API definition (basically a fancy TypeScript object that can feel like an OpenAPI spec), and both server and client import that to stay in sync.
This makes ts-rest a better fit for teams with separate repos or an existing REST API that cannot be migrated to tRPC. It works great even if your frontend team has never made eye contact with the backend team, and wants to keep it that way.
Unlike tRPC, which politely asks you to forget that HTTP exists and just “call procedures,” ts-rest is proudly, unapologetically RESTful. You define routes, methods (GET, POST, etc.), and return status codes like it’s 2015—but with type inference and zero boilerplate.
// contract.ts
import { initContract } from '@ts-rest/core';
const c = initContract();
export const userContract = c.router({
getUser: {
method: 'GET',
path: '/user/:id',
responses: {
200: c.type<{ id: string; name: string }>(),
},
},
});// server.ts
import express from 'express';
import { createExpressEndpoints } from '@ts-rest/express';
import { userContract } from './contract';
const app = express();
const router = {
getUser: async ({ params }) => ({
status: 200,
body: { id: params.id, name: 'John Doe' },
}),
};
createExpressEndpoints(userContract, router, app);
app.listen(3000, () => console.log('Server running on port 3000'));Honorable Mentions
Besidests-rest, there are a lot of other tools out there that might be a better fit for your needs.OpenAPI’s codegen is a decent option if you already have an OpenAPI spec and want to use it to generate types for your frontend and backend.
While this could be an easy win, as pretty much every TypeScript backend project out there is most likely based on an OpenAPI spec.
npx openapi-typescript https://api.example.com/openapi.json --output types.tsimport { paths } from './types';
type GetUserResponse = paths['/users/{id}']['get']['responses']['200']['content']['application/json'];Yeah, that’s not the sexiest way of accessing those types, but that’s the price of an easy win.
In addition to that, zodios is great for all those fans of zod.
It’s similar to ts-rest’s approach, creating API contracts from zod schemas.
import { makeApi } from '@zodios/core';
import { z } from 'zod';
const api = makeApi([
{
method: 'get',
path: '/users/:id',
alias: 'getUser',
parameters: [ /*...*/ ],
response: z.object({ id: z.string(), name: z.string() }),
},
]);
const client = new Zodios('https://myapi.com', api);
const user = await client.getUser({ params: { id: '123' } });Conclusion
So, should you drop everything and rebuild your API withtRPC?Well, maybe.
If you’re living in a TypeScript monorepo and tired of chasing your own tail with duplicate types, mismatched schemas, and half-baked docs, tRPC is like a cheat code.
It gives you type safety, tight integration, and a developer experience so smooth you’ll be turning other developers’ heads from the building across the street.
But remember: it’s not perfect.
If you’re working with other languages, exposing public APIs, or need protocol flexibility, tRPC might not be the right tool for the job.
There’s are a lot of other tools out there, like ts-rest and others, that might be a better fit for your needs.
But I think the principle behind all of these tools is the important part.
Modern APIs should be easy to use, easy to understand, and easy to change.
Regardless of the tool you choose, the fear of change is a velocity killer, and it eventually leads to bad practices and tech debts.
Due to the flexible and historical nature of how REST APIs have been built over the years, we tend to forget that there are better ways to build them.
Type-safety is not just a convenience, it’s a necessity.
It gives us a better, faster way to develop things, even at the cost of breaking changes.
After all, if you’re going to cry over breaking changes, you might as well do it with compiler errors and a glass of whiskey.
