tRPC lets you build type-safe APIs in TypeScript, no schema definitions, no code generation, no REST boilerplate. Your server defines procedures, and the client automatically knows all the types. Simple, fast, and safe.

What is tRPC? πŸ€”

Imagine building an API where your client automatically knows which endpoints exist, what parameters they expect, and what they return, without writing a single schema file. That's exactly what tRPC does.

tRPC stands for TypeScript Remote Procedure Call. It leverages the full power of TypeScript's type system to guarantee end-to-end type safety between server and client. No OpenAPI specs, no GraphQL schemas, no code generation, just pure TypeScript.

tRPC - Move Fast and Break Nothing
Experience the full power of TypeScript inference to boost productivity for your full-stack application.

End-to-End Type Safety Explained πŸ”’

The core principle of tRPC is simple: you define your API logic on the server, and the client automatically infers all types from it. No manual type synchronization, no forgotten fields.

This works because tRPC uses the typeof operator and TypeScript's type inference. The server exports a type (the so-called AppRouter), and the client imports this type, not the code, just the type. This way, the client knows exactly what's possible at compile time.

Warum du nur noch TypeScript nutzen solltest ☝️
TypeScript bringt Typsicherheit und bessere Entwicklererfahrung in deine JavaScript-Projekte.

Routers and Procedures πŸ›€οΈ

In tRPC, everything revolves around routers and procedures. A router is basically a collection of API endpoints, and a procedure is a single endpoint.

There are three types of procedures:

  • Query, For reading data (like GET in REST)
  • Mutation, For writing/modifying data (like POST/PUT/DELETE)
  • Subscription, For real-time updates via WebSockets

Here's a simple example of a router with different procedures:

import { initTRPC } from '@trpc/server';

const t = initTRPC.create();

const appRouter = t.router({
  // Simple query without input
  hello: t.procedure.query(() => {
    return { message: 'Hello World!' };
  }),

  // Query with input
  getUserById: t.procedure
    .input((val: unknown) => {
      if (typeof val === 'string') return val;
      throw new Error('Invalid ID');
    })
    .query(({ input }) => {
      // input is automatically typed as string here
      return { id: input, name: 'John Doe' };
    }),

  // Mutation
  createUser: t.procedure
    .input((val: unknown) => {
      // Simple validation
      const obj = val as { name: string; email: string };
      return obj;
    })
    .mutation(({ input }) => {
      // Create user...
      return { id: '1', ...input };
    }),
});

// Export this type for the client
export type AppRouter = typeof appRouter;

Input Validation with Zod βœ…

The manual input validation in the example above isn't ideal, of course. This is where Zod comes in, a runtime validation library that works perfectly with tRPC.

Zod: Runtime-Validierung fΓΌr TypeScript πŸ›‘οΈ
Zod bietet Runtime-Validierung mit voller TypeScript-Integration fΓΌr sichere Datenverarbeitung.

With Zod, input validation looks like this:

import { initTRPC } from '@trpc/server';
import { z } from 'zod';

const t = initTRPC.create();

const appRouter = t.router({
  createUser: t.procedure
    .input(
      z.object({
        name: z.string().min(2, 'Name must be at least 2 characters'),
        email: z.string().email('Invalid email address'),
        age: z.number().min(18).optional(),
      })
    )
    .mutation(({ input }) => {
      // input is now fully typed:
      // { name: string; email: string; age?: number }
      return { id: crypto.randomUUID(), ...input };
    }),

  getUsers: t.procedure
    .input(
      z.object({
        limit: z.number().min(1).max(100).default(10),
        cursor: z.string().optional(),
      })
    )
    .query(({ input }) => {
      // Pagination with type-safe input
      return {
        users: [],
        nextCursor: null,
      };
    }),
});

The beauty of it: Zod validates data at runtime while simultaneously inferring TypeScript types. So you get compile-time and runtime safety in one go.

Client Usage πŸ“±

On the client side, tRPC is extremely elegant. You create a typed client and call your procedures just like normal functions:

import { createTRPCClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from './server';

// Create client
const trpc = createTRPCClient<AppRouter>({
  links: [
    httpBatchLink({
      url: 'http://localhost:3000/trpc',
    }),
  ],
});

// Call queries – fully typed!
const greeting = await trpc.hello.query();
console.log(greeting.message); // "Hello World!"

// With input
const user = await trpc.getUserById.query('123');
console.log(user.name); // Type is automatically known

// Call mutation
const newUser = await trpc.createUser.mutate({
  name: 'Anna',
  email: '[email protected]',
  age: 25,
});
// newUser is fully typed: { id: string; name: string; email: string; age?: number }

The best part: if you rename a parameter on the server or change an endpoint, your editor immediately shows you all the places in the client that need updating. No guessing, no debugging runtime errors.

Middleware 🧱

Middleware in tRPC is incredibly powerful. You can use it to implement authentication, logging, rate limiting, and more, all in a type-safe way:

import { initTRPC, TRPCError } from '@trpc/server';
import { z } from 'zod';

interface Context {
  user?: { id: string; role: string };
}

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

// Auth middleware
const isAuthenticated = t.middleware(({ ctx, next }) => {
  if (!ctx.user) {
    throw new TRPCError({
      code: 'UNAUTHORIZED',
      message: 'You must be logged in',
    });
  }
  return next({
    ctx: {
      // ctx.user is now guaranteed to exist
      user: ctx.user,
    },
  });
});

// Logging middleware
const withLogging = t.middleware(async ({ path, type, next }) => {
  const start = Date.now();
  const result = await next();
  const duration = Date.now() - start;
  console.log(`${type} ${path} - ${duration}ms`);
  return result;
});

// Create protected procedure
const protectedProcedure = t.procedure
  .use(withLogging)
  .use(isAuthenticated);

const appRouter = t.router({
  // Public
  getPublicData: t.procedure.query(() => {
    return { data: 'Publicly accessible' };
  }),

  // Protected – auth required
  getSecretData: protectedProcedure.query(({ ctx }) => {
    // ctx.user is guaranteed to be defined here!
    return { secret: `Secret for ${ctx.user.id}` };
  }),

  updateProfile: protectedProcedure
    .input(z.object({ name: z.string() }))
    .mutation(({ ctx, input }) => {
      return { userId: ctx.user.id, newName: input.name };
    }),
});

Through the middleware chain, types are progressively refined. After the auth middleware, TypeScript knows that ctx.user exists, no manual type guards needed.

tRPC vs. REST vs. GraphQL βš–οΈ

Criterion REST GraphQL tRPC
Type Safety Manual (OpenAPI) Schema-based Automatic (TypeScript)
Code Generation Often needed Often needed Not needed
Learning Curve Low Medium-High Low
Languages All All TypeScript only
Overfetching Common Solved Not an issue*
Schema File Optional (OpenAPI) Required Not needed
Ecosystem Huge Large Growing
Ideal for Public APIs Complex data queries Full-stack TypeScript

* Since you define the return values yourself, there's no overfetching problem in the traditional sense.

GraphQL: Zukunft der APIs 🌐
GraphQL revolutioniert die Art, wie wir APIs bauen und konsumieren.

When Should You Use tRPC? 🎯

tRPC is perfect for you if:

  • You have a full-stack TypeScript project (e.g., Next.js, Nuxt, SvelteKit)
  • Server and client live in the same monorepo
  • You want to iterate quickly without maintaining schema files
  • Type safety is non-negotiable for you

tRPC is less suitable if:

  • Your API is consumed by non-TypeScript clients (mobile apps in Swift/Kotlin, other backend services)
  • You're offering a public API used by third parties
  • You use different languages in the backend and frontend

For public APIs, REST with OpenAPI or GraphQL is often the better choice. But for internal full-stack projects with TypeScript? tRPC is currently hard to beat.

Conclusion 🏁

tRPC is a game-changer for TypeScript developers. It eliminates the gap between server and client by maximizing TypeScript's type system. No boilerplate, no code generation, no schema synchronization, just type-safe procedure calls.

If you already have a TypeScript full-stack setup, you should definitely give tRPC a try. The developer experience is excellent, and the type safety gives you a confidence in your code that you can only achieve with REST or GraphQL through considerable extra effort.

Give it a shot, you'll wonder why you ever synchronized API types manually! πŸš€