Type-Safe End-to-End Development with tRPC and Zod
A shared TypeScript API where the client and server can never disagree. How tRPC infers types across the wire and Zod validates at the boundary.
Published on • August 10, 2026
AI Assistant

The classic full-stack lie: the frontend’s TypeScript types are just “agreements” with a backend that never reads them. You change the response shape on the server, the client still thinks it’s the old shape, and the error only shows up when a user hits the page. The fix is an API where the compiler verifies both sides of the wire against the same types — that’s what tRPC and Zod give you.
In this post, you will learn how tRPC builds a fully typed API with no code generation, how Zod defines the request/response contracts at runtime and compile time, and how the two compose into a stack where a wrong field on either side is a build error.
How tRPC works: no schemas, no codegen
tRPC lets you build and consume fully typesafe APIs by inference rather than by schema. You define a router with procedures; the client imports that router’s type and TypeScript derives every input and output type automatically. There’s no OpenAPI file, no code generation step, no --watch process — the types are just the types.
// server/trpc.ts
import { initTRPC } from "@trpc/server";
import { z } from "zod";
const t = initTRPC.create();
export const appRouter = t.router({
getUser: t.procedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
return { id: input.id, name: "Ada", roles: ["admin"] };
}),
updateName: t.procedure
.input(z.object({ id: z.string(), name: z.string().min(1).max(100) }))
.mutation(async ({ input, ctx }) => {
await db.users.update({ id: input.id, name: input.name });
return { ok: true };
}),
});
export type AppRouter = typeof appRouter;
The z.object({...}) is doing double duty: Zod validates at runtime, and tRPC infers the input type from it for both sides.
Mounting the router
tRPC mounts on your existing HTTP server via an adapter — Node HTTP, Express, Fastify, or Next.js:
// server/index.ts
import { createHTTPServer } from "@trpc/server/adapters/standalone";
import { appRouter } from "./trpc";
createHTTPServer({
router: appRouter,
createContext: () => ({ db }),
}).listen(3000);
The client: inference without a schema
On the client, you import the router type and create a typed client. No codegen, no duplicated type files, no drift:
// client/trpc.ts
import { createTRPCClient, httpBatchLink } from "@trpc/client";
import type { AppRouter } from "../server/trpc";
export const trpc = createTRPCClient<AppRouter>({
links: [httpBatchLink({ url: "http://localhost:3000" })],
});
Now every call is checked end to end:
const user = await trpc.getUser.query({ id: "1" });
// user is typed: { id: string, name: string, roles: string[] }
await trpc.updateName.mutate({ id: "1", name: "Ada" });
// ✅ works
// await trpc.getUser.query({ id: 1 });
// ❌ TS2345: Argument of type number is not assignable to string.
// await trpc.getUser.query({ name: "Ada" });
// ❌ TS2353: Object literal may only specify known properties.
Change roles: ["admin"] to roles: ["admin", "user"] on the server, and every client that reads user.roles re-types instantly. This is the tRPC promise: the contract is enforced, not documented.
Zod: validation and inference in one
Zod is the schema layer. It validates untrusted input at the boundary — which you must do regardless of TypeScript, because TypeScript disappears at compile time — and its types flow into tRPC automatically.
import { z } from "zod";
const UserInput = z.object({
email: z.string().email(),
age: z.number().int().min(13).max(120).optional(),
tags: z.array(z.string()).min(1).max(10).default([]),
});
// Runtime validation of untrusted input
const parsed = UserInput.safeParse(req.body);
if (!parsed.success) {
return { errors: parsed.error.flatten() };
}
parsed.data; // fully typed and validated
// Type derived from the schema — single source of truth
type UserInput = z.infer<typeof UserInput>;
tRPC runs the Zod input through .safeParse() automatically on every procedure call, so a malformed payload gets a typed error instead of a 500 in your resolver.
The full stack in one shared package
The cleanest setup is a monorepo (pnpm/turborepo) where the router and its types live in a shared package both sides import:
apps/
├── server/ → imports router, mounts adapter
├── web/ → imports type AppRouter, creates typed client
└── packages/
└── api/ → the router definition + Zod schemas (the single source of truth)
The web app imports type { AppRouter }; the server imports { appRouter }. Because both come from the same module, drift is impossible. This is tRPC’s sweet spot: shared code, not shared documentation.
React integration
With the React client, the hooks carry types too:
import { createTRPCReact } from "@trpc/react-query";
const trpc = createTRPCReact<AppRouter>();
function UserProfile({ id }: { id: string }) {
const { data, isLoading } = trpc.getUser.useQuery({ id });
const update = trpc.updateName.useMutation({
onSuccess: () => trpc.getUser.invalidate({ id }),
});
if (isLoading) return <p>Loading…</p>;
return (
<div>
<h2>{data?.name}</h2>
<button onClick={() => update.mutate({ id, name: "Ada Lovelace" })}>
Rename
</button>
</div>
);
}
Query keys, caching, and invalidation are derived from the procedures — you never hand-write a query key string that can go stale.
When tRPC isn’t the right fit
tRPC’s model is TypeScript-everywhere. It’s the wrong tool when:
- You need public API consumers in other languages (Python, mobile-native, third parties) — there’s no OpenAPI contract to share. Consider exporting OpenAPI from your Zod schemas (e.g.
@asteasolutions/zod-to-openapi) or a REST/GraphQL API. - You need strict versioned external contracts with cross-language tooling.
- Your frontend and backend are already in different languages.
For an internal, all-TypeScript product, tRPC is unbeatable ergonomics; for public APIs, pair your schemas with an OpenAPI export.
Putting It All Together
A complete runnable example — a monorepo with a shared packages/api router, a standalone server adapter, and a React client with typed hooks, all wired with Zod-validated inputs — is in this repository. Set it up, change a schema, and watch the client type error the moment you touch a mismatched field.
Conclusion & Next Steps
You now have a type-safe end-to-end stack: Zod for runtime validation at the boundary, tRPC for inferred types that can’t drift, and a monorepo layout that makes the router the single source of truth. Next steps: add tRPC subscriptions for real-time updates, export OpenAPI from your Zod schemas for the external API you don’t want to rewrite, and set up request batching with httpBatchLink (it’s on by default in most templates).
References / Sources
- tRPC documentation — introduction and concepts. https://trpc.io/docs
- tRPC server and client usage guides. https://trpc.io/docs/server/overview
- tRPC with React (TanStack Query integration). https://trpc.io/docs/client/tanstack-react-query/setup
- Zod — schema declaration and validation. https://zod.dev