The Joys of tRPC
Once 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 and rendering UI on the client. REST-style JSON APIs became the usual way to serve that data.
If the server ever changed a field name or response shape, you found out at runtime (often in production).
OpenAPI helped by describing those APIs in a spec, but the contract still lived outside the code. The frontend had to redefine request and response types locally 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 it directly, with no generated SDK or handwritten fetch wrappers. If the server changes a field, the client sees it at compile time.
Server-defined contracts
You define procedures on the server and collect them into a router that the client consumes:
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. Your API contract is plain TypeScript, which is nice to work with.
Auth boundaries
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 check right next to the procedure. You don’t need to cross-reference route files and middleware chains to figure out who can call what.
Input validation
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’s 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 on its next type check. The schema is the contract, so the two can’t drift apart.
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 or changes its input shape, the client breaks at compile time instead of later in production.
This is my favorite part: tRPC removes the glue code that used to sit between backend and frontend types. In a monorepo, it feels like calling a remote function with good guardrails rather than integrating with an HTTP API.
Error handling
When a procedure throws a TRPCError, the client receives a structured error with a code and message plus 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 come back as BAD_REQUEST errors, but by default the field-level issues are only serialized into the error message.
To get them as structured data, add an error formatter when you create the tRPC instance:
import { ZodError } from "zod";
const t = initTRPC.context<Context>().create({
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError:
error.cause instanceof ZodError ? error.cause.flatten() : null,
},
};
},
});
Now the client can read err.data?.zodError?.fieldErrors and show errors next to each form field.
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 so pleasant that teams can forget each one still crosses the network, where it can be slow, fail, or hit a client running an older build. 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 and gives you end-to-end types without maintaining a second source of truth.