Asia/Dhaka
Posts

Transitioning from REST to tRPC: End-to-End Type Safety for TypeScript APIs

November 5, 2024
When building full-stack applications with a separate frontend and backend, the API layer is historically the weakest link in the chain. Whether you use REST or GraphQL, you define your endpoints on the server. Then, on the frontend, you write fetch calls or Apollo queries to consume those endpoints. Two codebases, two separate sources of truth, connected by nothing more than convention and documentation. The core issue is synchronization. If you change a property name on the backend from userName to username, your TypeScript compiler won't complain. Your backend builds perfectly. Your frontend builds perfectly. But the moment a user clicks a button, the app crashes because the frontend expects userName, which is now undefined. The type system that's supposed to catch exactly this kind of error never sees across the network boundary. Developers have tried to solve this for years with code generation tools — Swagger/OpenAPI codegen, GraphQL Code Generator — to produce shared TypeScript interfaces from a schema. These setups work, but they're notoriously brittle: they require a separate build step, a schema that has to stay in sync manually, and generated code that's often out of date the moment someone forgets to re-run the generator. tRPC (TypeScript Remote Procedure Call) provides a radically simpler solution for full-stack TypeScript applications, especially in frameworks like Next.js. It gives you end-to-end type safety without any code generation step at all. How does it work? Because your frontend and backend live in the same repository (a monorepo) and both use TypeScript, tRPC lets the frontend directly import the type definitions of your backend routers — not the implementation, just the types. There's no schema file, no .graphql definitions, no generated client SDK to keep in sync. The type flows automatically, the same way it would if you called a function defined three files up in the same project.
RESTGraphQLtRPC
Type safety across client/serverManual (or via codegen)Via codegen from schemaAutomatic, no codegen
Requires a schema definition languageNoYes (SDL)No — plain TypeScript
Build step to sync typesYes, if using codegenYesNo
Works for public/third-party APIsYesYesNo (TypeScript monorepo only)
Learning curveLowModerate–highLow, if you already know TypeScript
Best fitPublic APIs, simple CRUDComplex, client-driven data needsInternal full-stack TypeScript apps
This table is the crux of the decision: tRPC isn't competing to be a universal API standard. It's optimized for a specific, increasingly common setup — a single team, a single TypeScript codebase, a tightly coupled frontend and backend — and it trades API portability for developer experience within that boundary.
With tRPC, you define your backend routes using standard TypeScript functions and a validation library like Zod.
Typescript
// server/routers/user.ts
import { z } from 'zod';
import { publicProcedure, router } from '../trpc';

export const userRouter = router({
  // Define a query (GET request)
  getUserById: publicProcedure
    .input(z.string()) // Input validation
    .query(async (opts) => {
      const user = await db.user.findById(opts.input);
      return user; // TypeScript infers the return type
    }),

  // Define a mutation (POST/PUT/DELETE-equivalent)
  updateUsername: publicProcedure
    .input(z.object({ id: z.string(), username: z.string().min(3) }))
    .mutation(async (opts) => {
      return db.user.update({
        where: { id: opts.input.id },
        data: { username: opts.input.username },
      });
    }),
});
On the frontend, calling this API feels exactly like calling a local asynchronous JavaScript function. You use the tRPC React Query hooks, which wrap @tanstack/react-query under the hood, giving you caching, refetching, and loading states for free.
Tsx
// client/components/UserProfile.tsx
import { trpc } from '../utils/trpc';

export function UserProfile({ id }: { id: string }) {
  // If you forget the 'id', TypeScript will throw an error!
  const { data, isLoading } = trpc.user.getUserById.useQuery(id);

  if (isLoading) return <div>Loading...</div>;

  // TypeScript knows EXACTLY what 'data' looks like based on the backend return type
  return <div>{data.name}</div>;
}
Mutations follow the same pattern, with useMutation instead of useQuery:
Tsx
const updateUsername = trpc.user.updateUsername.useMutation();

function handleSave(id: string, newName: string) {
  updateUsername.mutate({ id, username: newName });
}

A minimal tRPC setup in a Next.js App Router project involves a few pieces working together:
  1. Define the context — what's available to every procedure (database client, session, request headers).
  2. Initialize tRPC and export reusable building blocks (router, publicProcedure, and a protectedProcedure for authenticated routes).
  3. Compose routers into a single appRouter, which becomes your single source of truth for the entire API surface.
  4. Expose an HTTP handler at a single Next.js route (typically /api/trpc/[trpc]) that all tRPC calls go through.
  5. Wire up the client with a tRPC React provider at the root of your app, pointing at that same endpoint.
Typescript
// server/trpc.ts
import { initTRPC } from '@trpc/server';

const t = initTRPC.context<Context>().create();

export const router = t.router;
export const publicProcedure = t.procedure;
export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
  if (!ctx.session) throw new Error('UNAUTHORIZED');
  return next({ ctx: { ...ctx, session: ctx.session } });
});
Typescript
// server/routers/_app.ts
import { router } from '../trpc';
import { userRouter } from './user';
import { postRouter } from './post';

export const appRouter = router({
  user: userRouter,
  post: postRouter,
});

export type AppRouter = typeof appRouter; // this single type powers the entire frontend
That final exported type — AppRouter — is the entire integration surface. The frontend imports only this type, never any server-side implementation code, which keeps database logic and secrets safely out of the client bundle.
  1. Instant Feedback. If you change a backend return type from number to string, your frontend IDE immediately shows a red squiggly line wherever that data is consumed — no build step, no waiting for a codegen script to run.
  2. No GraphQL Boilerplate. GraphQL is powerful, but defining schemas, resolvers, and queries is overkill for many internal applications. tRPC gives you comparable type safety with the simplicity of writing and calling plain functions.
  3. Built-in Validation. By integrating tightly with Zod, input validation is handled on the server, and the exact validation shape (required fields, string lengths, formats) is reflected automatically in the frontend types.
  4. Refactoring Confidence. Renaming a field, changing a return shape, or restructuring a router shows up as compiler errors across the entire codebase instantly — refactors that would be terrifying in a loosely-typed REST setup become mechanical.
  5. Minimal Dependencies. No schema language to learn, no separate query language, no additional build tooling beyond TypeScript itself and the tRPC package.
  6. Works Naturally with React Query. Caching, background refetching, optimistic updates, and pagination all come from React Query's proven patterns rather than a bespoke client library.

tRPC is not a universal API standard like REST or GraphQL, and it's important to be honest about its boundaries before adopting it.
  • It requires a TypeScript monorepo. tRPC is specifically designed for full-stack TypeScript environments where the frontend and backend share the same codebase, or at minimum the same package for shared types. There's no cross-language contract the way OpenAPI or GraphQL SDL provides.
  • It doesn't work for public or third-party APIs. If you need to expose an API for external consumers — a mobile app written in Swift or Kotlin, a partner's Python integration, a public developer API — you still need REST or GraphQL, since those clients can't import your TypeScript types.
  • Versioning is less mature. REST and GraphQL both have well-established conventions for versioning a public contract over time. tRPC, built around a single shared type rather than a published schema, doesn't have the same battle-tested versioning story for APIs consumed outside your own deploy.
  • Team boundaries can blur. In organizations where frontend and backend are owned by genuinely separate teams (rather than the same full-stack engineers), the tight coupling that makes tRPC convenient can also make it harder to enforce clean API contracts between teams.

  • Choose tRPC if you're building an internal web application, own both the frontend and backend in a single TypeScript codebase, and want maximum developer velocity with zero codegen overhead.
  • Choose GraphQL if you have multiple client types with very different data needs (web, iOS, Android) and want clients to request exactly the fields they need, or if you're exposing a flexible API to external partners.
  • Choose REST if you need a simple, widely understood, cacheable API — especially for public-facing APIs where broad client compatibility and tooling maturity matter more than developer ergonomics.
None of these is strictly "better" in the abstract — each optimizes for a different constraint: tRPC for internal velocity, GraphQL for client flexibility, REST for universal compatibility.
tRPC solves a very specific, very common pain point: the silent drift between what your backend returns and what your frontend expects. For teams building internal tools, SaaS dashboards, or any full-stack TypeScript application where the same engineers (or tightly coordinated teams) own both ends of the stack, tRPC removes an entire category of runtime bugs at compile time, with none of the schema and codegen overhead GraphQL demands. If you're building an internal web application where you control both ends of the stack, tRPC offers a developer experience that's genuinely hard to go back from once you've used it. But if you need to serve a public API, support non-TypeScript clients, or maintain a long-lived versioned contract for external consumers, REST or GraphQL remain the right tools for the job. The best full-stack architectures increasingly treat this as a per-project decision rather than a dogmatic one — use tRPC where it fits, and reach for REST or GraphQL where it doesn't.
On this page
Hey! Got questions?Ask me anything 👋