Frameworks

tRPC: Building End-to-End Type-Safe APIs Without Code Generation

tRPC: Building End-to-End Type-Safe APIs Without Code Generation

What Is tRPC and Why Should You Care?

If you have ever built a TypeScript application with a separate frontend and backend, you know the pain of keeping API types synchronized. You define a response shape on the server, then manually recreate that type on the client. When the server contract changes, the client silently breaks — often in production, where a user discovers the bug before your test suite does.

tRPC eliminates this problem entirely. It gives you end-to-end type safety between your TypeScript server and client without any code generation step, schema files, or build-time compilation. You define a function on the server, and the client immediately knows the input it expects, the output it returns, and every possible error it can throw — all through standard TypeScript inference.

Unlike traditional REST APIs where you manually document endpoints, or GraphQL where you maintain a schema and run code generators, tRPC works by sharing types directly through TypeScript’s type system. There are no .graphql files, no openapi.yaml specifications, and no codegen commands to remember. You write your server logic, export a type, and import it on the client. That is the entire workflow.

This approach has made tRPC one of the fastest-growing API tools in the TypeScript ecosystem. It powers production applications at companies ranging from startups to enterprises, and it integrates seamlessly with frameworks like Next.js, Nuxt, and SvelteKit. If your stack is TypeScript on both ends, tRPC might be the most productive API layer you have never used.

How tRPC Works Under the Hood

Understanding tRPC’s architecture helps you appreciate why it is both simpler and more reliable than alternatives. At its core, tRPC consists of three pieces: a server that defines procedures, a client that calls those procedures, and a shared type that connects them.

Procedures: The Building Block

In tRPC, every API endpoint is a procedure. A procedure is either a query (for reading data), a mutation (for writing data), or a subscription (for real-time updates via WebSockets). Each procedure has an optional input validator, optional middleware, and a resolver function that contains your business logic.

Procedures are grouped into routers, and routers can be nested to create a hierarchical API structure. A typical application might have a userRouter, a postRouter, and a commentRouter, each containing related procedures. These routers merge into a single appRouter whose type is exported and shared with the client.

The Type Bridge

Here is the key insight: tRPC never transmits types over the network. Types exist only at compile time in TypeScript. What tRPC does is let you export the type of your server router and import it on the client side. The client library uses this type to infer exactly what procedures are available, what inputs they accept, and what outputs they return. The result is full autocompletion and compile-time error checking across the network boundary.

At runtime, tRPC sends standard HTTP requests (or WebSocket messages) with JSON payloads. There is nothing proprietary about the transport. You can inspect tRPC requests in your browser’s network tab just like any other API call. The magic is entirely in the type system.

Input Validation with Zod

tRPC integrates deeply with Zod for runtime input validation. When you define a Zod schema as the input for a procedure, tRPC uses it for two purposes simultaneously: it validates incoming data at runtime (rejecting malformed requests before they reach your business logic), and it infers the TypeScript type from the schema so the client knows exactly what shape of data to send. This dual use means you write your validation logic once, and both runtime safety and compile-time types flow from that single source of truth.

Setting Up a tRPC Project from Scratch

Let us build a practical tRPC application step by step. We will create a task management API that demonstrates queries, mutations, input validation, error handling, and middleware — the patterns you will use in every real project.

Server Setup

First, install the required dependencies. tRPC v11 (the current stable release) works with any Node.js HTTP server, but it provides first-class adapters for Express, Fastify, Next.js, and standalone deployments.

The following example demonstrates a complete tRPC server with a task management router. It includes input validation, error handling, authenticated procedures, and the patterns you will reuse across every project.

// server/trpc.ts — Initialize tRPC and define reusable middleware
import { initTRPC, TRPCError } from '@trpc/server';
import { z } from 'zod';

// Context type — available in every procedure
interface Context {
  userId: string | null;
  db: DatabaseClient;
}

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

// Public procedure — no authentication required
export const publicProcedure = t.procedure;

// Protected procedure — requires authentication
const isAuthenticated = t.middleware(({ ctx, next }) => {
  if (!ctx.userId) {
    throw new TRPCError({
      code: 'UNAUTHORIZED',
      message: 'You must be logged in to perform this action',
    });
  }
  return next({
    ctx: { ...ctx, userId: ctx.userId }, // Narrow the type: userId is now string, not string | null
  });
});

export const protectedProcedure = t.procedure.use(isAuthenticated);
export const router = t.router;

// server/routers/tasks.ts — Task management procedures
const taskSchema = z.object({
  title: z.string().min(1).max(200),
  description: z.string().max(2000).optional(),
  priority: z.enum(['low', 'medium', 'high']),
  dueDate: z.string().datetime().optional(),
});

export const taskRouter = router({
  // Query: Get all tasks for the authenticated user
  list: protectedProcedure
    .input(
      z.object({
        status: z.enum(['all', 'active', 'completed']).default('all'),
        sortBy: z.enum(['createdAt', 'dueDate', 'priority']).default('createdAt'),
        limit: z.number().min(1).max(100).default(20),
        cursor: z.string().nullish(), // For cursor-based pagination
      })
    )
    .query(async ({ ctx, input }) => {
      const tasks = await ctx.db.task.findMany({
        where: {
          userId: ctx.userId,
          ...(input.status !== 'all' && {
            completed: input.status === 'completed',
          }),
        },
        orderBy: { [input.sortBy]: 'desc' },
        take: input.limit + 1,
        cursor: input.cursor ? { id: input.cursor } : undefined,
      });

      let nextCursor: string | undefined;
      if (tasks.length > input.limit) {
        const nextItem = tasks.pop();
        nextCursor = nextItem?.id;
      }

      return { tasks, nextCursor };
    }),

  // Mutation: Create a new task
  create: protectedProcedure
    .input(taskSchema)
    .mutation(async ({ ctx, input }) => {
      const task = await ctx.db.task.create({
        data: {
          ...input,
          userId: ctx.userId,
          completed: false,
        },
      });
      return task;
    }),

  // Mutation: Toggle task completion
  toggleComplete: protectedProcedure
    .input(z.object({ id: z.string().uuid() }))
    .mutation(async ({ ctx, input }) => {
      const task = await ctx.db.task.findUnique({
        where: { id: input.id, userId: ctx.userId },
      });
      if (!task) {
        throw new TRPCError({
          code: 'NOT_FOUND',
          message: 'Task not found',
        });
      }
      return ctx.db.task.update({
        where: { id: input.id },
        data: { completed: !task.completed },
      });
    }),
});

// server/root.ts — Merge all routers
export const appRouter = router({
  tasks: taskRouter,
});

// Export the router type — this is what the client imports
export type AppRouter = typeof appRouter;

Notice that the AppRouter type export at the bottom is a type-only export. It carries zero runtime cost and adds nothing to your server bundle. Yet it gives the client complete knowledge of every procedure, input, and output in your API.

Client Setup

The client connects to the server by importing the AppRouter type and creating a typed tRPC client. Here is how you use it in a React application with tRPC’s React Query integration.

// client/trpc.ts — Create the typed client
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '../server/root';

export const trpc = createTRPCReact<AppRouter>();

// client/components/TaskList.tsx — Use tRPC in React components
import { trpc } from '../trpc';
import { useState } from 'react';

export function TaskList() {
  const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'completed'>('all');

  // Full autocompletion: input shape, output shape, error types
  const { data, isLoading, fetchNextPage, hasNextPage } =
    trpc.tasks.list.useInfiniteQuery(
      { status: statusFilter, limit: 20 },
      { getNextPageParam: (lastPage) => lastPage.nextCursor }
    );

  // Mutations with automatic cache invalidation
  const createTask = trpc.tasks.create.useMutation({
    onSuccess: () => {
      utils.tasks.list.invalidate(); // Refetch the task list
    },
  });

  const toggleComplete = trpc.tasks.toggleComplete.useMutation({
    onSuccess: () => {
      utils.tasks.list.invalidate();
    },
  });

  const utils = trpc.useUtils();

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

  return (
    <div>
      <select
        value={statusFilter}
        onChange={(e) => setStatusFilter(e.target.value as typeof statusFilter)}
      >
        <option value="all">All Tasks</option>
        <option value="active">Active</option>
        <option value="completed">Completed</option>
      </select>

      {data?.pages.flatMap((page) =>
        page.tasks.map((task) => (
          <div key={task.id}>
            <label>
              <input
                type="checkbox"
                checked={task.completed}
                onChange={() => toggleComplete.mutate({ id: task.id })}
              />
              {task.title} — {task.priority}
            </label>
          </div>
        ))
      )}

      {hasNextPage && (
        <button onClick={() => fetchNextPage()}>Load more</button>
      )}

      <button
        onClick={() =>
          createTask.mutate({
            title: 'New task',
            priority: 'medium',
            // TypeScript error if you pass an invalid field
            // or miss a required one
          })
        }
      >
        Add Task
      </button>
    </div>
  );
}

Every call to trpc.tasks.* is fully typed. If you rename a procedure on the server, every client call immediately shows a TypeScript error. If you add a required input field, the client refuses to compile until it provides that field. If you change a return type, the client components that consume that data are flagged for updates. There is no way for the client and server to fall out of sync without the compiler telling you.

tRPC vs REST vs GraphQL: Choosing the Right Approach

tRPC is not a replacement for REST or GraphQL in every scenario. Each approach has distinct strengths, and the right choice depends on your team, your stack, and your project’s constraints.

When tRPC Wins

Full TypeScript stacks. If both your server and client are TypeScript, tRPC gives you a developer experience that neither REST nor GraphQL can match. The type inference is instantaneous, there is no codegen step to run, and refactoring flows naturally through the entire codebase.

Internal APIs. When the API serves a known set of clients that you control (your own web app, your own mobile app built with React Native), tRPC’s tight coupling between server and client is an advantage, not a liability. You trade flexibility for safety.

Rapid prototyping and small teams. tRPC has remarkably little boilerplate. Defining an endpoint takes a few lines, and there are no schema files, resolver maps, or API documentation to maintain. For startups and small teams shipping fast, this reduction in overhead is significant. Tools like Taskee can help manage the development workflow when your team scales beyond the prototype stage.

When REST or GraphQL Still Make Sense

Public APIs. If external developers consume your API, REST with OpenAPI or GraphQL with its introspection system provides the discoverability and language-agnostic access that tRPC cannot. tRPC is TypeScript-only by design — there is no Python client, no Go client, and no way to call a tRPC procedure from a non-TypeScript environment without building a custom adapter.

Polyglot backends. If parts of your backend are in Python, Go, or Rust, tRPC cannot span across language boundaries. REST with shared OpenAPI specifications or well-designed API contracts is the practical choice for multi-language architectures.

Complex data requirements with many consumers. GraphQL shines when multiple clients need different projections of the same data. A mobile app that fetches five fields and a desktop dashboard that fetches thirty can both query the same GraphQL endpoint efficiently. tRPC procedures return fixed shapes — if you need this kind of flexibility, GraphQL remains the better tool.

Comparison Summary

Feature tRPC REST GraphQL
Type safety Automatic (inference) Manual or codegen Codegen required
Code generation Not needed Optional (OpenAPI) Required for types
Language support TypeScript only Any language Any language
Learning curve Low (for TS devs) Low Moderate-High
Schema definition Zod validators OpenAPI/Swagger SDL (.graphql files)
Over/under-fetching Fixed responses Fixed responses Client-specified
Public API suitability Poor Excellent Good
Real-time support Subscriptions SSE/WebSockets Subscriptions

Advanced tRPC Patterns for Production

Once you have the basics running, several patterns help tRPC scale to production-grade applications.

Middleware Composition

tRPC middleware can be chained to build reusable behavior layers. Common patterns include logging, rate limiting, permission checks, and request timing. Each middleware can modify the context and pass enriched data to the next middleware or the final resolver.

For example, you might compose an isAuthenticated middleware with an isAdmin middleware to create a adminProcedure that requires both conditions. This composition model is similar to how Express middleware works, but with full type safety through the context chain. If your application handles sensitive operations, combining tRPC middleware with a proper authentication and authorization strategy ensures secure access control at every layer.

Error Handling

tRPC provides a TRPCError class with standard HTTP-like error codes: BAD_REQUEST, UNAUTHORIZED, FORBIDDEN, NOT_FOUND, INTERNAL_SERVER_ERROR, and others. These errors are automatically serialized and transmitted to the client, where they can be caught and handled in a type-safe way.

For custom error formatting, tRPC allows you to define an errorFormatter when initializing the router. This lets you attach additional metadata to errors (such as Zod validation details) without breaking the standard error contract. The client receives these custom fields with proper TypeScript types.

Optimistic Updates

Because tRPC integrates with React Query, you get access to its full optimistic update mechanism. When a user creates a task, you can immediately update the UI to show the new task before the server responds. If the mutation fails, React Query automatically rolls back the optimistic change. This pattern makes tRPC applications feel instantaneous, even on slow connections.

Batching

tRPC automatically batches multiple concurrent requests into a single HTTP call. If your page loads and triggers five queries simultaneously, tRPC sends them as one request and demultiplexes the responses on the client. This reduces connection overhead and improves page load performance without any developer effort. You can monitor the impact of batching and other API performance metrics using application observability tools.

Integration with Next.js App Router

tRPC pairs exceptionally well with the Next.js App Router. In this setup, tRPC procedures can be called directly from React Server Components without an HTTP roundtrip — the server-side code calls the procedure function directly, while the same procedures remain accessible to client components via the standard tRPC client. This dual access model means your API layer works identically in both server and client contexts.

Advanced TypeScript Patterns in tRPC

tRPC’s power stems from sophisticated use of TypeScript features: conditional types, mapped types, template literal types, and recursive type inference. If you want to understand how tRPC achieves its type inference or build custom abstractions on top of it, a solid foundation in advanced TypeScript patterns is essential.

One particularly useful technique is creating type-safe API clients for different environments. You can define a createCaller function for server-side usage in tests and scripts, alongside the standard HTTP client for browser usage. Both share the same AppRouter type, so your test code has identical type safety to your production client code.

Another pattern worth adopting is output validation. While most developers focus on input validation (ensuring the client sends correct data), you can also add Zod schemas to your procedure outputs. This ensures that your resolver functions return data matching the declared contract — catching bugs where database queries return unexpected shapes or optional fields are accidentally omitted.

When Not to Use tRPC

tRPC is an excellent tool with clear boundaries. Understanding those boundaries prevents frustration and helps you make informed architectural decisions.

Do not use tRPC if your API serves non-TypeScript clients. Mobile teams using Swift or Kotlin cannot consume tRPC endpoints without a translation layer. If you need to serve a diverse set of clients, REST with OpenAPI or GraphQL provides the language-agnostic contracts those clients require.

Do not use tRPC for APIs that need public documentation. tRPC has no built-in equivalent to Swagger UI or GraphQL Playground. There is no standard way for external developers to discover your API’s capabilities. If public discoverability matters, choose a protocol with established documentation tooling.

Do not force tRPC into a microservices architecture without planning. tRPC assumes a single TypeScript server that serves a known client. In a microservices setup where multiple services need to communicate, tRPC can work between individual service pairs, but coordinating types across a fleet of services requires additional tooling — monorepo setups with shared packages, or type registries. For complex multi-team architectures, working with an experienced development partner can help establish the right API strategy from the start.

Do not use tRPC if your team is not comfortable with TypeScript. tRPC’s value proposition is entirely dependent on static typing. In a JavaScript-only codebase, tRPC provides no benefit over plain REST endpoints. The investment in TypeScript adoption must come before the investment in tRPC.

Getting Started: Next Steps

If tRPC fits your project, the onboarding path is straightforward. Install @trpc/server and @trpc/client (plus @trpc/react-query if you use React). Define a router, export its type, and create a client. The official tRPC documentation includes quickstart guides for every supported framework.

For existing projects, tRPC can be adopted incrementally. You do not need to rewrite your entire API layer at once. Start by building new endpoints with tRPC while your existing REST endpoints continue serving traffic. Over time, migrate endpoints as you touch them. This incremental approach reduces risk and lets your team build tRPC expertise gradually.

The tRPC ecosystem also includes useful companion libraries: trpc-openapi generates OpenAPI specifications from tRPC routers (useful if you need REST compatibility), and trpc-playground provides a testing UI similar to GraphQL Playground. These tools address some of tRPC’s limitations around discoverability and debugging.

For teams building full-stack TypeScript applications in 2025 and beyond, tRPC represents a genuine leap forward in API development. It removes an entire category of bugs — type mismatches between client and server — and replaces tedious manual synchronization with automatic compiler verification. Once you experience the workflow, going back to hand-maintained API types feels like writing code without syntax highlighting: technically possible, but unnecessarily painful.