tRPC in Practice: End-to-End Type Safety, Zod Validation, and React Query Integration
Why tRPC Is Worth Using
Traditional frontend/backend collaboration relies on "hand-written API docs + separately maintained types," which drift apart the moment a type changes. tRPC's core pitch: your backend procedure IS the type source — the frontend gets full type inference on call, with no codegen and no schema files.
| Approach | Type source | Needs codegen | Cross-language |
|---|---|---|---|
| REST + hand types | manual | no | yes |
| GraphQL + codegen | schema file | yes | yes |
| tRPC | derived from backend proc | no | no (TS/TS only) |
Defining Routers and Procedures
// server/trpc.ts
import { initTRPC } from "@trpc/server";
import { z } from "zod";
const t = initTRPC.create();
export const router = t.router;
export const publicProcedure = t.procedure;
// server/routers/user.ts
export const userRouter = router({
byId: publicProcedure
.input(z.object({ id: z.string().uuid() }))
.query(({ input }) => {
return db.user.findUnique({ where: { id: input.id } });
}),
create: publicProcedure
.input(z.object({ name: z.string().min(1), email: z.string().email() }))
.mutation(({ input }) => {
return db.user.create({ data: input });
}),
});
query maps to reads, mutation to writes. Input is declared with Zod, and tRPC validates it at runtime automatically.
For complex nested inputs, run the shape through the JSON Formatter tool first, then translate it into a Zod schema to avoid missing fields.
React Query Integration
This is the classic combo: tRPC owns types, React Query owns caching/retry/invalidation.
// client/trpc.ts
import { createTRPCReact } from "@trpc/react-query";
import type { AppRouter } from "../server/routers";
export const trpc = createTRPCReact<AppRouter>();
function UserList() {
const users = trpc.user.list.useQuery();
const utils = trpc.useUtils();
const create = trpc.user.create.useMutation({
onSuccess: () => utils.user.list.invalidate(),
});
if (users.isLoading) return <p>Loading…</p>;
return (
<ul>
{users.data?.map((u) => (
<li key={u.id}>{u.name}</li>
))}
<button onClick={() => create.mutate({ name: "Ada", email: "a@x.com" })}>
Add
</button>
</ul>
);
}
Note that the type of users.data and the argument type of create.mutate are all inferred from the backend procedure — change the backend input and the frontend errors immediately.
Middleware and Auth
import { initTRPC, TRPCError } from "@trpc/server";
const t = initTRPC.context<{ jwt?: string }>().create();
const isAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.jwt) throw new TRPCError({ code: "UNAUTHORIZED" });
return next({ ctx: { ...ctx, userId: verify(ctx.jwt) } });
});
export const protectedProcedure = t.procedure.use(isAuthed);
Middleware can rewrite ctx, so downstream procedures receive userId.
When validating JWTs, use the JWT Decoder tool to inspect
suband expiry in the payload and quickly verify the auth chain.
Error Handling
tRPC unifies errors with TRPCError; the frontend distinguishes them via error.data.code:
protectedProcedure.query(({ ctx }) => {
const u = db.user.findUnique({ where: { id: ctx.userId } });
if (!u) throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
return u;
});
Frontend:
const { error } = trpc.user.me.useQuery();
if (error?.data?.code === "NOT_FOUND") return <p>Not found</p>;
Error codes map 1:1 to HTTP status codes; when debugging, the HTTP Status Codes tool maps UNAUTHORIZED=401, NOT_FOUND=404, etc.
Subscriptions (Realtime)
tRPC supports WebSocket-based subscriptions:
export const appRouter = router({
onMessage: publicProcedure.subscription(({ signal }) => {
return observable<Message>((emit) => {
const timer = setInterval(() => emit.next({ text: "ping" }), 1000);
return () => clearInterval(timer);
});
}),
});
Smooth Migration from REST
- Map boundaries: "reads" →
query, "writes/side effects" →mutation. - Add Zod: give every endpoint an input schema — you get runtime validation for free.
- Replace client: swap
fetch+ hand types fortrpc.xxx.useQuery/useMutation. - Sink auth: consolidate scattered JWT checks into the
protectedProceduremiddleware. - Keep REST: tRPC can also expose HTTP endpoints via a fetch adapter for incremental migration.
FAQ
Q1: Can tRPC be cross-language?
No. It relies on TypeScript type inference; both ends must be TS. For multi-language, fall back to REST/GraphQL.
Q2: How does it compare to GraphQL?
It removes schema files and codegen — types are the source code. The trade-off is losing cross-language and public-API capability.
Q3: What happens on Zod validation failure?
It automatically becomes a BAD_REQUEST error; the frontend sees error.data.code === "BAD_REQUEST" with the Zod issue list attached.
Q4: Can I avoid React Query?
Yes. The tRPC core is client-agnostic and has a native client caller, but the React Query integration has the best DX.
Q5: How do I paginate large lists?
Add cursor/limit to the input, return { items, nextCursor } from the query, and pair it with useInfiniteQuery on the frontend.
Recommended Tools
In tRPC full-stack development, these tools help:
- JSON Formatter — shape inputs/outputs before writing Zod
- JWT Decoder — inspect auth token
suband expiry - HTTP Status Codes — map tRPC error codes to HTTP semantics
- Base64 Encoder — handle credentials and binary payloads
tRPC is not "yet another RPC framework" but a "types as contract" paradigm: the backend procedure becomes the frontend's type source, eliminating docs, codegen, and the mental overhead of matching fields on both sides. Change a backend input and the editor instantly reds the frontend — that end-to-end type safety is the sweet spot of modern full-stack TS teams.
Try these browser-local tools — no sign-up required →