The Joys of tRPC
Once REST and XMLHttpRequest made it practical to fetch data from the server without a full page reload, the browser took on a new role: consuming remote data, interpreting it in JavaScript, and rendering UI on the client.
If the server changed a field name or response shape, you found out at runtime (usually in production).
JSON APIs and later OpenAPI improved the situation, but the contract still lived in a fuzzy middle ground. The frontend had to redefine request and response types locally, write defensive parsing code, and hope the deployed server still matched what the client had compiled against.
This is the problem tRPC solves well.
In a full-stack TypeScript application, tRPC lets the server define the contract once and the client consume that contract directly. No generated SDK or handwritten fetch wrappers. If the server changes a field, the client sees it at compile time.
Server-defined contracts
The basic model is simple: define procedures on the server, collect them into a router, and export the router type for the client to consume.
At a high level, it looks like this:
import { initTRPC, TRPCError } from "@trpc/server";
import { z } from "zod";
type Context = {
user: { id: string } | null;
};
const t = initTRPC.context<Context>().create();
export const router = t.router;
export const publicProcedure = t.procedure;
From there, the server becomes a set of procedures with explicit input and output shapes. This is what I love most about tRPC: the API contract is just normal application code.
Auth boundaries
One pattern that works well is to define a public base procedure and layer authentication on top of it for protected routes:
const isAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.user) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next({
ctx: {
user: ctx.user,
},
});
});
export const protectedProcedure = t.procedure.use(isAuthed);
Then the router reads clearly:
export const appRouter = router({
healthcheck: publicProcedure.query(() => {
return { ok: true };
}),
me: protectedProcedure.query(({ ctx }) => {
return {
id: ctx.user.id,
};
}),
// assumes a `db` module for data access
getPost: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
return db.posts.findById(input.id);
}),
});
Compared to many REST stacks, this keeps the auth model closer to the procedure itself. You don’t need to cross-reference route files, middleware chains, and separate schema definitions to understand who can call what.
Input validation
Input validation is where the developer experience really starts to click.
tRPC pairs naturally with Zod, so the same schema validates runtime input and informs compile-time types:
const createPostInput = z.object({
title: z.string().min(1).max(120),
body: z.string().min(1),
tags: z.array(z.string()).max(5),
});
export const appRouter = router({
createPost: protectedProcedure
.input(createPostInput)
.mutation(async ({ ctx, input }) => {
return db.posts.create({
authorId: ctx.user.id,
title: input.title,
body: input.body,
tags: input.tags,
});
}),
});
Because the schema is a Zod object, you can also infer a plain TypeScript type from it:
type CreatePostInput = z.infer<typeof createPostInput>;
// { title: string; body: string; tags: string[] }
tRPC does this automatically for the procedure’s input parameter, so you rarely need to write it yourself.
It is useful when you want to share the type elsewhere, like tests or form validation on the client.
If the client sends invalid input, the procedure never runs. If the server changes the schema, the client sees the new types immediately.
That closes a long-standing gap in web development. The contract is no longer a separate document that can drift from the implementation.
A typesafe client
Once the router is defined, you export its type:
export type AppRouter = typeof appRouter;
The client can then consume that type directly:
import { createTRPCProxyClient, httpBatchLink } from "@trpc/client";
import type { AppRouter } from "@acme/api";
export const trpc = createTRPCProxyClient<AppRouter>({
links: [
httpBatchLink({
url: "https://example.com/api/trpc",
}),
],
});
And now the calling code gets full autocomplete and type checking:
const post = await trpc.getPost.query({ id: "123" });
await trpc.createPost.mutate({
title: "Hello",
body: "World",
tags: ["typescript"],
});
If the server renames getPost, changes its input, or alters its return type, the client breaks at compile time instead of later in production.
That is the real joy of tRPC. It removes a layer of glue code that used to sit between backend types and frontend types. In a monorepo, it often feels less like integrating with an HTTP API and more like calling a remote function with good guardrails.
Error handling
When a procedure throws a TRPCError, the client receives a structured error with a code, message, and any additional data attached to it.
On the client side, you can catch these errors and handle them by error code:
import { TRPCClientError } from "@trpc/client";
try {
await trpc.createPost.mutate({ title: "", body: "", tags: [] });
} catch (err) {
if (err instanceof TRPCClientError) {
console.error(err.data?.code); // "BAD_REQUEST"
}
}
Zod validation failures surface as BAD_REQUEST errors with the full list of field-level issues in the response.
This means the client can display granular form errors without any extra coordination between frontend and backend.
Trade-offs
tRPC isn’t a universal fit.
Its sweet spot is a system where one team owns both sides of the stack in TypeScript. If you need third-party consumers in other languages, or a long compatibility window across independent clients, OpenAPI and a conventional JSON API are still easier to publish.
There’s also some abstraction risk. tRPC makes remote calls feel so ergonomic that teams can forget they’re designing a network boundary with latency, caching, failure modes, and versioning concerns. Types help a lot, but they don’t remove the need for good API design.
Still, for a full-stack TypeScript app, I think tRPC is one of the nicest tools in the current web stack. It keeps the contract close to the code, validates inputs at runtime, and gives you end-to-end types without maintaining a second source of truth.