The Friction of Traditional REST
For years, REST has been my default choice when building APIs. It is simple, well-understood, and universally supported. However, as I built more full-stack applications using modern frameworks like Next.js and TypeScript, the friction between the frontend and backend started to grow.
In my previous project—a web app with complex user dashboards and interactive forms—I followed the standard REST pattern. I defined my API endpoints in Next.js API routes, wrote manual validation schemas using Zod, created TypeScript interfaces for request and response payloads, and consumed them on the frontend using fetch or React Query.
While this setup worked, it introduced several subtle challenges:
- Type Duplication: Every time I changed a backend data shape, I had to manually update the corresponding frontend TypeScript types or rely on code generation tools like OpenAPI spec generators.
- Runtime Errors: If I forgot to update a frontend interface after modifying a database field, typescript wouldn't throw a build-time error on the frontend. The mismatch was only caught during testing or, worse, in production.
- Boilerplate Fatigue: Writing URL strings, query param serializations, and boilerplate fetch wrappers for dozens of endpoints felt repetitive and prone to typos.
I realized I was spending too much time maintaining the boundary between my server and client, rather than building actual features.
Discovering tRPC: End-to-End Type Safety Without CodeGen
I decided to refactor the project's API layer to tRPC. The promise of tRPC is simple: it allows you to build end-to-end typesafe APIs without GraphQL or code generation. It leverages TypeScript's powerful type inference to share types directly between server and client code.
Here is how the transition looked in practice.
The REST Approach
Previously, fetching user profiles required an API route and a typed fetcher on the client:
// pages/api/user/[id].ts
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { id } = req.query;
const user = await db.user.findUnique({ where: { id: String(id) } });
res.status(200).json(user);
}
// components/UserProfile.tsx
interface User {
id: string;
name: string;
email: string;
}
const fetchUser = async (id: string): Promise<User> => {
const res = await fetch(`/api/user/${id}`);
return res.json();
};
The tRPC Approach
With tRPC, I defined a procedure on the server:
// server/routers/user.ts
import { z } from 'zod';
import { publicProcedure, router } from '../trpc';
export const userRouter = router({
getById: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
return await db.user.findUnique({ where: { id: input.id } });
}),
});
And consumed it directly on the client using React hooks:
// components/UserProfile.tsx
import { trpc } from '../utils/trpc';
export function UserProfile({ userId }: { userId: string }) {
const { data: user, isLoading } = trpc.user.getById.useQuery({ id: userId });
if (isLoading) return <div>Loading...</div>;
return <div>{user?.name}</div>;
}
Notice that no custom TypeScript interfaces were written on the client side. user was automatically typed based on the return type of db.user.findUnique!
The Key Benefits Experienced
After refactoring the application, the improvements to my daily development workflow were immediate:
- Instant Refactoring: If I rename a database column in Prisma or modify a procedure return shape on the server, TypeScript instantly highlights every broken line in my frontend component files during build time.
- Auto-completion Everywhere: My IDE now offers full autocomplete for endpoint paths and input parameters. Typing
trpc.suggests all available routers and procedures automatically. - Integrated React Query: tRPC wraps
@tanstack/react-queryout of the box, giving me powerful caching, background refetching, and optimistic updates without extra configuration. - Faster Feature Velocity: I stopped writing API documentation for myself. The types are the documentation.
When Is tRPC Not the Right Choice?
While tRPC has transformed my workflow for monolithic full-stack apps, it isn't a silver bullet for every project:
- Public APIs: If you are building an API meant to be consumed by third-party developers or mobile apps written in Swift/Kotlin, REST or GraphQL remain the industry standard.
- Polyglot Stacks: If your backend is written in Go or Python and your frontend is React, you cannot leverage tRPC's direct type inference.
Final Thoughts
Moving from REST to tRPC was one of the most impactful architectural decisions I made in my recent project. By eliminating API client boilerplate and establishing true end-to-end type safety, I cut down development time significantly and eliminated a whole class of runtime bugs.
If you are building a full-stack Web application with TypeScript on both sides, I highly recommend giving tRPC a try on your next project.