Building full-stack TypeScript applications often leads to duplicate effort: maintaining backend schemas, regenerating OpenAPI specs, and manually writing client-side fetch types.
Moving our internal dashboard to tRPC removed this glue code entirely by sharing types directly between client and server.
The Problem with Manual Contracts
In a standard setup, backend changes can quietly desynchronize from the client until a runtime error occurs:
- Type Drift: Backend modifies a response field, but frontend interfaces remain stale.
- Boilerplate Fatigue: Maintaining DTOs, Zod schemas, and client SDKs across separate repositories.
- Over-fetching: Rigid REST endpoints serving unnecessary nested payloads.
Code Comparison
Instead of manually typing fetch calls and route handlers:
// server/routers/user.ts
import { router, publicProcedure } from '../trpc';
import { z } from 'zod';
export const userRouter = router({
getById: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input, ctx }) => {
const user = await ctx.db.user.findUnique({
where: { id: input.id },
select: { id: true, name: true, email: true },
});
return user;
}),
});
export type AppRouter = typeof userRouter;