paulserban.eu

Writing Edition

Paul Serban

Writing, snippets & book notes

tRPC: End-to-End Type-Safe APIs Without the Boilerplate

Build Fully Typed Client-Server Communication in TypeScript - No Schema, No Code Generation Required

Introduction

There is a tax every full-stack TypeScript team pays silently. You define a type on the server. You duplicate it on the client. You write a fetch wrapper. You write a Zod schema. You run a code generator. You update the OpenAPI spec. And still, the moment someone renames a field on the backend, the frontend compiles cleanly - and breaks at runtime.

tRPC (TypeScript Remote Procedure Call) attacks this problem at the root. Rather than generating types from a schema or maintaining a separate contract layer, tRPC lets your TypeScript types flow directly from server to client through the module system itself. There is no schema to maintain, no code generation step, no runtime contract validation overhead beyond what you already write. If your server code changes, the TypeScript compiler tells your client immediately - before the code ships.

This article is a deep technical introduction to tRPC: what it is, the problem it was designed to solve, how it works under the hood, how to set it up in a real project, and where its boundaries lie. Whether you are evaluating it for a greenfield project or considering migrating away from REST or GraphQL, this guide will give you the foundation to make an informed decision.

The Problem: Type Safety Across the Network Boundary

The network boundary is where TypeScript's guarantees traditionally stop. You write a handler that returns { user: User; token: string }. On the client side, you call fetch("/api/auth/login") and cast the result with as LoginResponse - a type assertion that the compiler trusts blindly. The two types are structurally identical today, but they are maintained separately and will eventually diverge.

The industry has developed several strategies to address this. REST with OpenAPI generates client SDKs from a YAML or JSON spec, but the spec is a third artifact that must be kept in sync with the implementation. GraphQL solves the schema-sharing problem elegantly with its type system, but introduces a query language, a resolver architecture, and a code generation pipeline that carries real complexity costs. gRPC gives you strong contracts and generated stubs but brings Protobuf, a compilation step, and a binary wire format that is difficult to debug and not natively browser-friendly without a proxy layer.

All three approaches share a common pattern: they introduce an intermediate representation - a schema, a query language, a .proto file - and tools to synchronize that representation with both sides of the system. The pain is proportional to the size of that intermediate layer and the discipline required to keep it current.

tRPC takes a different philosophical position. It argues that if your client and server are both TypeScript (or can share TypeScript types), the intermediate representation is redundant. The server's type signature is the contract. The client can import and use it directly. The elimination is radical but coherent: when you remove the schema, you also remove the code generation, the sync problem, and the runtime mismatch.

What tRPC Is - and What It Is Not

tRPC is a TypeScript-first library for building and consuming type-safe APIs. It provides a router abstraction on the server that exposes typed procedures - queries, mutations, and subscriptions - and an isomorphic client that consumes those procedures with full type inference.

It is important to be precise about what tRPC is not. It is not a new transport protocol. tRPC communicates over HTTP by default, using standard GET requests for queries and POST requests for mutations. It is not a replacement for REST in a polyglot environment: because its type safety mechanism depends on sharing TypeScript types at the module level, tRPC works only when both sides of the boundary are TypeScript. If a mobile app written in Swift needs to call your API, tRPC offers no advantage over a well-typed REST API with an OpenAPI spec.

tRPC is also not a framework for public APIs. It is purpose-built for internal, same-codebase communication - the canonical deployment is a monorepo where a Next.js frontend and a Node.js backend share a packages/ directory. The moment your API must serve external consumers who are not running TypeScript and cannot import your router types, tRPC's core proposition dissolves.

Understanding this scope is critical. tRPC is not trying to compete with GraphQL for public, consumer-facing APIs. It is solving a different, narrower problem: eliminating the type-synchronization tax in the most common pattern of modern web development - a TypeScript monorepo with a single team owning both client and server.

Core Architecture and How Type Inference Works

To understand tRPC's mechanism, you need to understand one key TypeScript feature: conditional type inference across module boundaries. When you export a type from a TypeScript module and import it in another, TypeScript propagates the full structural type, including generics, without any runtime artifact being required. tRPC is fundamentally an exploitation of this mechanism applied to an HTTP abstraction layer.

On the server, you create a router object using tRPC's initTRPC factory. Each procedure is a typed handler that declares its input schema (typically via Zod) and returns a typed output. The router aggregates these procedures into a single TypeScript type - the AppRouter type - which is a structural map of every endpoint, its input shape, and its inferred output shape.

// 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
import { router, publicProcedure } from "../trpc";
import { z } from "zod";
import { db } from "../db";

export const userRouter = router({
  getById: publicProcedure
    .input(z.object({ id: z.string().uuid() }))
    .query(async ({ input }) => {
      const user = await db.user.findUniqueOrThrow({ where: { id: input.id } });
      return user; // TypeScript infers the full return type from db schema
    }),

  create: publicProcedure
    .input(
      z.object({
        name: z.string().min(2),
        email: z.string().email(),
      }),
    )
    .mutation(async ({ input }) => {
      return db.user.create({ data: input });
    }),
});

// server/root.ts
import { router } from "./trpc";
import { userRouter } from "./routers/user";

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

export type AppRouter = typeof appRouter; // ← This is the entire contract

The AppRouter type is exported from the server package. The client imports only this type - not the implementation, not any runtime module - and passes it as a generic parameter to the tRPC client factory.

// client/trpc.ts
import { createTRPCReact } from "@trpc/react-query";
import type { AppRouter } from "../server/root"; // ← type-only import, zero runtime cost

export const trpc = createTRPCReact<AppRouter>();

From this point, trpc.user.getById.useQuery({ id: '...' }) is fully typed. The input type is inferred from the Zod schema. The output type is inferred from the query's return statement. If the server's getById procedure is renamed to findById, the client call site produces a compile-time error - not a 404 at runtime.

The runtime mechanism is simpler than the type machinery might suggest. When the client calls a query, tRPC sends a GET request to a path like /api/trpc/user.getById?input={"id":"..."}. For mutations, it sends a POST. The server receives this, deserializes the input, validates it against the Zod schema, runs the handler, and returns serialized JSON. The type information exists only at compile time - at runtime, tRPC is a thin HTTP layer with JSON on both ends.

Setting Up tRPC in a Real Project

A minimal but realistic tRPC setup in a Next.js monorepo demonstrates the practical integration points. The following walkthrough covers the server adapter, context construction, middleware, and React Query integration - the four areas where most configuration work happens.

Installation

# Core packages
npm install @trpc/server @trpc/client @trpc/react-query @trpc/next
npm install zod @tanstack/react-query

# For Next.js App Router (v10+) or Pages Router (v10 superjson setup)
npm install superjson

Server Initialization with Context

Context is how tRPC gives your procedures access to request-scoped data - authenticated user sessions, database connections, or request headers. The context is constructed once per request and is typed:

// server/context.ts
import { getServerSession } from "next-auth";
import { authOptions } from "./auth";
import { db } from "./db";
import type { CreateNextContextOptions } from "@trpc/server/adapters/next";

export async function createContext({ req, res }: CreateNextContextOptions) {
  const session = await getServerSession(req, res, authOptions);

  return {
    db,
    session,
    req,
    res,
  };
}

export type Context = Awaited<ReturnType<typeof createContext>>;
// server/trpc.ts
import { initTRPC, TRPCError } from "@trpc/server";
import superjson from "superjson";
import type { Context } from "./context";

const t = initTRPC.context<Context>().create({
  transformer: superjson, // enables Date, Map, Set serialization
});

// Base procedures
export const router = t.router;
export const publicProcedure = t.procedure;

// Protected procedure - middleware enforces authentication
export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
  if (!ctx.session?.user) {
    throw new TRPCError({ code: "UNAUTHORIZED" });
  }
  return next({
    ctx: {
      ...ctx,
      // TypeScript narrows session to non-null after this check
      session: { ...ctx.session, user: ctx.session.user },
    },
  });
});

The Next.js API Handler

// pages/api/trpc/[trpc].ts
import { createNextApiHandler } from "@trpc/server/adapters/next";
import { appRouter } from "../../../server/root";
import { createContext } from "../../../server/context";

export default createNextApiHandler({
  router: appRouter,
  createContext,
  onError:
    process.env.NODE_ENV === "development"
      ? ({ path, error }) => {
          console.error(`tRPC error on ${path ?? "unknown"}:`, error);
        }
      : undefined,
});

Client-Side Provider Setup

// app/providers.tsx (App Router) or _app.tsx (Pages Router)
'use client';

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client';
import superjson from 'superjson';
import { useState } from 'react';
import { trpc } from '../utils/trpc';

export function TRPCProvider({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(() => new QueryClient());
  const [trpcClient] = useState(() =>
    trpc.createClient({
      transformer: superjson,
      links: [
        httpBatchLink({
          url: '/api/trpc',
          // Optional: attach auth headers
          async headers() {
            return {};
          },
        }),
      ],
    })
  );

  return (
    <trpc.Provider client={trpcClient} queryClient={queryClient}>
      <QueryClientProvider client={queryClient}>
        {children}
      </QueryClientProvider>
    </trpc.Provider>
  );
}

Consuming a Procedure in a Component

// components/UserProfile.tsx
'use client';

import { trpc } from '../utils/trpc';

export function UserProfile({ userId }: { userId: string }) {
  const { data, isLoading, error } = trpc.user.getById.useQuery(
    { id: userId },
    {
      staleTime: 60_000,       // Cache for 1 minute
      retry: 2,
      enabled: Boolean(userId),
    }
  );

  const updateUser = trpc.user.update.useMutation({
    onSuccess: () => {
      // Invalidate and refetch
      trpc.useContext().user.getById.invalidate({ id: userId });
    },
  });

  if (isLoading) return <p>Loading...</p>;
  if (error) return <p>Error: {error.message}</p>;

  return (
    <div>
      <h1>{data.name}</h1>
      <p>{data.email}</p>
    </div>
  );
}

The data object here is fully typed as the Prisma return type of db.user.findUniqueOrThrow - no type assertion, no manual interface, no code generation.

Request Batching, Subscriptions, and the Link System

tRPC ships with a batching mechanism that is enabled by default through httpBatchLink. When multiple query hooks fire simultaneously - a common pattern in a React component tree - tRPC combines them into a single HTTP request. The server processes them in parallel and returns a batched response. This significantly reduces waterfall latency in applications with multiple concurrent data fetches, and it happens transparently without any application-level coordination.

The link system is tRPC's middleware layer for the client. Links are composable functions that sit between the client call and the HTTP transport. The most useful built-in links are httpBatchLink for standard HTTP batching, splitLink for routing queries and subscriptions to different transports, and loggerLink for development debugging. You can write custom links for things like request signing, metrics instrumentation, or circuit breaking:

import {
  httpBatchLink,
  loggerLink,
  splitLink,
  unstable_httpSubscriptionLink,
} from "@trpc/client";

trpc.createClient({
  transformer: superjson,
  links: [
    // Log in development only
    loggerLink({ enabled: (opts) => process.env.NODE_ENV === "development" }),

    // Route subscriptions to SSE, everything else to HTTP batch
    splitLink({
      condition: (op) => op.type === "subscription",
      true: unstable_httpSubscriptionLink({ url: "/api/trpc" }),
      false: httpBatchLink({ url: "/api/trpc" }),
    }),
  ],
});

Subscriptions are tRPC's real-time primitive. They are implemented over Server-Sent Events (SSE) or WebSockets (via @trpc/server/adapters/ws) and exposed as typed async iterables on the server:

// server/routers/notifications.ts
import { router, protectedProcedure } from "../trpc";
import { observable } from "@trpc/server/observable";
import { EventEmitter } from "events";

const ee = new EventEmitter();

export const notificationsRouter = router({
  onNotification: protectedProcedure.subscription(({ ctx }) => {
    return observable<{ message: string; timestamp: Date }>((emit) => {
      const onNotify = (data: { message: string; timestamp: Date }) => {
        emit.next(data);
      };

      ee.on(`notify:${ctx.session.user.id}`, onNotify);

      return () => {
        ee.off(`notify:${ctx.session.user.id}`, onNotify);
      };
    });
  }),
});

The client subscribes with trpc.notifications.onNotification.useSubscription(), and the received data is fully typed.

Trade-offs and Pitfalls

The primary limitation of tRPC is its TypeScript exclusivity. The type-sharing mechanism works because TypeScript types are erased at runtime - the client imports a type, not a value, meaning no server code is bundled into the client. But this also means the contract is invisible to any non-TypeScript consumer. A React Native app in a separate repository, a Python service, or a third-party integration cannot benefit from tRPC's type system. For teams running genuinely polyglot architectures, tRPC provides no contract enforcement across language boundaries, and a parallel REST or GraphQL API remains necessary.

Bundle size and monorepo discipline are practical concerns. Because the AppRouter type is imported by the client, the type must be accessible without importing runtime code. This requires careful barrel export discipline in your monorepo: the server package's index.ts must export the type without also pulling in server-only code such as database clients or file system access. In Next.js this is typically safe because type-only imports are tree-shaken, but misconfigurations can result in server modules leaking into the client bundle. Using import type consistently, and validating bundle composition with tools like @next/bundle-analyzer, is essential.

tRPC's error handling model deserves careful attention. TRPCError maps to HTTP status codes and carries a code property (e.g., 'NOT_FOUND', 'UNAUTHORIZED', 'BAD_REQUEST') as well as an optional cause for the underlying exception. By default, internal server errors do not expose their cause to clients in production, which is correct behavior. However, teams accustomed to throwing arbitrary errors from handlers and catching them on the client need to migrate to explicit TRPCError construction, and error boundary components need to inspect the data.httpStatus and data.code fields rather than raw HTTP status codes.

Scaling tRPC horizontally follows the same patterns as any stateless HTTP service. Queries and mutations are straightforward. Subscriptions, however, require shared state for the event emitter pattern to work across multiple server instances - a Redis pub/sub adapter or a similar message broker becomes necessary. This is not unique to tRPC (WebSocket-based real-time systems face the same challenge universally), but it is worth acknowledging before adopting subscriptions in a horizontally scaled deployment.

Best Practices

Organize routers by domain, not by HTTP method. The natural unit of tRPC organization is a feature domain - userRouter, postRouter, billingRouter - not a CRUD layer. Procedures within a router should represent business operations (createPost, publishPost, archivePost) rather than generic data access (updatePost with arbitrary patch input). This aligns tRPC's procedure model with the command/query responsibility segregation pattern and produces more readable, auditable code.

Use middleware for cross-cutting concerns, not conditional logic in handlers. Authentication, authorization, request logging, and rate limiting all belong in procedure middleware, composed at the router or procedure level. Writing if (!ctx.session) in every handler is a maintenance anti-pattern. Define protectedProcedure, adminProcedure, and similar base procedures once and compose them:

// A rate-limited, authenticated procedure for sensitive operations
export const sensitiveOperation = protectedProcedure
  .use(rateLimitMiddleware({ limit: 10, window: "1m" }))
  .use(auditLogMiddleware);

Validate on input, trust on output. Zod input schemas are the primary defense against malformed data at the procedure boundary. Output validation via .output(schema) is available but has performance costs and should be reserved for procedures where the output shape cannot be statically guaranteed - for instance, when calling an external API whose response you do not fully control.

Use superjson as the transformer from day one. The default tRPC serializer handles JSON-safe types only. Date objects returned from your database ORM will arrive at the client as ISO strings and require manual conversion. superjson transparently handles Date, Map, Set, BigInt, undefined, and NaN across the wire. Adding it after the fact requires coordinated changes to both client and server configuration.

Co-locate server and client in a monorepo, but maintain explicit package boundaries. The AppRouter type should be exported from a well-defined server package entry point. The client should import it as a type-only dependency. Do not allow client code to import server implementation modules, even accidentally. Tools like ESLint's no-restricted-imports or TypeScript's paths configuration can enforce this boundary automatically.

Analogies and Mental Models

The most useful mental model for tRPC is the transition from a distributed system to a function call. In a traditional client-server architecture, the network boundary forces you to treat the server as an external system: you communicate via a documented interface (the API spec), you write adapters (the fetch wrapper), and you maintain a separate type system for the communication protocol. tRPC collapses this: calling trpc.user.getById.useQuery({ id }) is semantically closer to calling getUserById({ id }) in the same module than it is to making an HTTP request. The network is an implementation detail.

A secondary analogy comes from compiler design. Type systems in compilers work by flowing type information through the program's structure - a function's parameter types constrain what can be passed to it, and its return type constrains what can be done with the result. tRPC extends this flow across the HTTP boundary. The server's router is a type environment. The client's import of AppRouter is a type import that exposes that environment. The TypeScript compiler enforces the constraints at every call site, exactly as it would for a local function.

The 80/20 Insight

If you absorb only one conceptual insight from tRPC, it is this: type safety at the API boundary is a build-time property, not a runtime property. Runtime contract enforcement (OpenAPI validation, GraphQL schema validation, Protobuf parsing) is valuable but expensive. It requires tooling, schema maintenance, and code generation. tRPC achieves the same contract enforcement at zero runtime cost by making the contract a TypeScript type - something the compiler already checks for free.

The practical implication is that the setup overhead for tRPC is front-loaded but shallow. Installing the packages, wiring up the Next.js adapter, and exporting AppRouter takes less than an hour. After that, the system maintains itself: every time the server API changes, the TypeScript compiler reports all affected call sites instantly, without any developer manually updating schemas or regenerating clients. For a team that previously spent time maintaining OpenAPI specs or GraphQL schemas, that is a permanent reduction in toil.

Conclusion

tRPC is not a universal solution to API design. It makes an explicit trade: it works only in TypeScript monorepos, and it gives up interoperability with non-TypeScript clients in exchange for a level of type safety and developer experience that schema-based approaches cannot match within the same constraints.

For the increasingly common deployment pattern - a TypeScript backend and a TypeScript frontend in the same repository, owned by the same team - that trade is almost always worth making. You eliminate an entire class of bugs (runtime type mismatches across the network boundary), an entire category of maintenance work (keeping schemas synchronized with implementations), and an entire layer of tooling (code generation pipelines). What you get in return is a system where the TypeScript compiler is your API contract, and a changed procedure signature is caught at compile time everywhere it is called, in the same way a renamed local function would be.

tRPC v10 and v11 have brought the library to a level of maturity where it can be adopted with confidence in production. Its integration with TanStack Query provides first-class support for caching, background refetching, optimistic updates, and infinite queries. Its link system provides extensibility for custom transports and middleware. And its community - driven substantially by the T3 Stack ecosystem - has produced well-tested patterns for Next.js, Remix, Expo, and standalone Express deployments.

If you are building a TypeScript full-stack application and you control both sides of the network boundary, tRPC should be your default choice for client-server communication. The question is not whether you can afford the migration cost - it is whether you can afford to keep paying the type-synchronization tax indefinitely.

Key Takeaways

Five practical steps you can apply immediately:

  1. Audit your current API type strategy. Identify how many places in your codebase use manual type assertions, as ResponseType, or hand-maintained interfaces that mirror backend response shapes. This is your baseline cost.
  2. Set up a minimal tRPC router in an isolated branch. You do not need to migrate your entire API at once. Pick one endpoint, wire it through tRPC, and validate the developer experience end-to-end before committing.
  3. Add superjson as the transformer from the start. Retrofitting it later requires coordinated changes across both client and server configuration.
  4. Define your base procedures (publicProcedure, protectedProcedure) before writing any router handlers. This gives you a clean middleware composition point for authentication, authorization, and logging.
  5. Use ESLint's no-restricted-imports to enforce the type-only import boundary. Prevent accidental server code leakage into your client bundle from day one.

References