tRPC: End-to-End Type Safety for TypeScript Apps

I explain how tRPC enforces API type safety without code generation, show how routers and procedures work, and cover when to use it over REST or GraphQL.

tRPC: End-to-End Type Safety for TypeScript Apps

You change a function’s return type on the server. The TypeScript compiler immediately flags every client-side call that reads the old shape. You fix them, save, done.

That workflow — where the type system catches API mismatches at compile time rather than runtime — is exactly what tRPC delivers. No schema file to maintain separately from your code. No code generation step to run. No runtime overhead to pay for. The types are inferred directly from the server implementation and shared with the client through TypeScript’s own type system.

This post covers what tRPC is, how its router and procedure model works, how Zod fits in for runtime validation, how the client gets those types without importing any server code, how to add tRPC to a Next.js or Express project, and where it stops being the right tool.

On this page

What tRPC actually is (and what it isn’t)

tRPC is a library for building typesafe APIs in TypeScript. The “RPC” stands for Remote Procedure Call — you define functions on the server and call them from the client, without thinking in terms of URL paths or HTTP methods. The “t” is for TypeScript.

What separates tRPC from GraphQL or a JSON schema approach is the mechanism. tRPC doesn’t generate code and doesn’t require you to maintain a separate schema alongside your implementation. Instead, it has TypeScript infer the input and output types of every API function directly from your server code. The client gets those types through a single type import — not the runtime code — so nothing from the server bundle ships to the client.

This is worth being explicit about: tRPC requires TypeScript on both ends. It’s built for full-stack TypeScript projects. If you’re building a public API for external developers, a service consumed by mobile apps in Swift or Kotlin, or a backend that needs an OpenAPI spec for documentation, tRPC is the wrong tool. It solves a specific problem — keeping a TypeScript client in sync with a TypeScript server — and it does that very well.

The library is split into packages. @trpc/server goes on the backend. @trpc/client goes on the frontend. Framework integrations like @trpc/next or @trpc/tanstack-react-query add hooks and adapters on top of the vanilla client. The core requires TypeScript 5.7.2 or newer with strict mode enabled.

If you’re not yet comfortable with TypeScript’s type inference and generics, the TypeScript for JavaScript Developers post covers the concepts tRPC leans on, and TypeScript Generics goes deeper on the type-parameter patterns you’ll see throughout the tRPC API.

Routers and procedures: the core building blocks

Everything starts with initTRPC. Call it once per application and export the helpers you need from it:

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

const t = initTRPC.create();

export const router = t.router;
export const publicProcedure = t.procedure;

The documentation is firm about this: initialise t exactly once. Multiple instances cause type conflicts that are frustrating to debug.

A procedure is a single API function. A router groups procedures together and gives them names. Together, they define the contract TypeScript will use to connect the two sides:

// server/appRouter.ts
import { z } from 'zod';
import { publicProcedure, router } from './trpc';

type User = { id: string; name: string };

export const appRouter = router({
  userList: publicProcedure
    .query(async () => {
      // return your users from a database here
      const users: User[] = [{ id: '1', name: 'Ada' }];
      return users;
    }),

  userCreate: publicProcedure
    .input(z.object({ name: z.string() }))
    .mutation(async ({ input }) => {
      const user: User = { id: crypto.randomUUID(), name: input.name };
      return user;
    }),
});

// Export the type only — not the implementation.
export type AppRouter = typeof appRouter;

Two kinds of procedures matter for most apps:

  • Query — reads data, maps to HTTP GET, can be batched and cached.
  • Mutation — creates, updates, or deletes, maps to HTTP POST, communicates side effects.

The export type AppRouter = typeof appRouter line is what makes the whole thing work. You export the type of the router object — not the router itself. TypeScript captures the full shape: procedure names, input types, output types. The client imports this type and uses it to get autocomplete and type checking across the API boundary, without ever touching the server implementation.

For larger applications, routers can be nested. You define userRouter, postRouter, and commentRouter in separate files, then merge them into one root appRouter. The type inference works through any depth of nesting.

Zod input validation: making inputs safe too

Even in a TypeScript app, inputs arriving from the network are untrusted at runtime. A procedure’s TypeScript type tells the compiler what you expect, but it can’t stop a manually crafted request or an older client from sending something unexpected. You need runtime validation.

tRPC lets you attach an input parser to any procedure through .input(). Zod is the most common choice:

import { z } from 'zod';
import { publicProcedure } from './trpc';

const userByIdProcedure = publicProcedure
  .input(z.string().uuid())
  .query(async ({ input }) => {
    // input is typed as string and validated as a UUID at runtime
    const user = await db.user.findUnique({ where: { id: input } });
    return user;
  });

The Zod schema does two jobs at once: it validates the input at runtime (throwing a typed tRPC error if the value fails) and it narrows TypeScript’s type for input inside the resolver function. One declaration covers both concerns.

You can use Yup or Valibot if you prefer them. Custom validator functions work too — anything that returns a typed value or throws on bad input. The requirement is that the function narrows the type when validation passes.

For object inputs with multiple fields:

const createPostProcedure = publicProcedure
  .input(
    z.object({
      title: z.string().min(1).max(200),
      content: z.string().min(1),
      published: z.boolean().default(false),
    }),
  )
  .mutation(async ({ input }) => {
    // input.title, input.content, input.published — all typed and validated
    return await db.post.create({ data: input });
  });

The client — calling procedures with inferred types

This is where the end-to-end part of the name earns its place. On the client, you import AppRouter as a type — only the type — and pass it as a type parameter when creating the client:

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

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

// Both calls are fully typed — no manual annotation needed.
const users = await trpc.userList.query();
const newUser = await trpc.userCreate.mutate({ name: 'Ada' });

The import type syntax strips the import entirely at build time. No server code reaches the client bundle. The types only exist in the type checker.

A few things worth noting about this setup:

httpBatchLink automatically combines multiple procedure calls made at roughly the same time into a single HTTP request. If three components on a page each query a different procedure on mount, those three calls become one network round trip. You get that behaviour for free.

Your editor’s autocomplete works across the API boundary. Type trpc. and the available procedures appear. Type a procedure name and you see what inputs it expects and what the return type is. This is genuinely useful when working in a large codebase where the server and client code sit in different directories — you don’t have to navigate to the server file to know what a procedure returns.

Before you continue: What happens on the client if you rename a procedure on the server? TypeScript will show a type error at the call site — before you run any code. The same happens if you change an input type or a return shape.

That’s the guarantee tRPC provides. The contract is the TypeScript type, and the type checker enforces it.

Adapters — adding tRPC to Next.js or Express

tRPC ships adapters for most common server environments. For a standalone Node.js server:

import { createHTTPServer } from '@trpc/server/adapters/standalone';
import { appRouter } from './appRouter';

createHTTPServer({ router: appRouter }).listen(3000);

For an existing Express server, tRPC mounts as middleware on a specific path and leaves your existing routes untouched:

import express from 'express';
import * as trpcExpress from '@trpc/server/adapters/express';
import { appRouter } from './appRouter';

const app = express();

app.use(
  '/trpc',
  trpcExpress.createExpressMiddleware({
    router: appRouter,
    createContext: () => ({}),
  }),
);

app.listen(4000);

Your REST endpoints at /api/users still work. tRPC handles everything under /trpc/*. This makes it easy to introduce tRPC incrementally into a project that already has REST routes — a pattern worth considering if you’re migrating. I covered building a REST API with Express in the REST API with Node.js and Express post if you need a reference for the existing structure.

For Next.js Pages Router, create a catch-all API route at pages/api/trpc/[trpc].ts:

import * as trpcNext from '@trpc/server/adapters/next';
import { appRouter } from '../../server/routers/_app';

export default trpcNext.createNextApiHandler({
  router: appRouter,
  createContext: () => ({}),
});

Then on the client side, createTRPCNext from @trpc/next creates typed React Query hooks:

// utils/trpc.ts
import { createTRPCNext } from '@trpc/next';
import { httpBatchLink } from '@trpc/client';
import type { AppRouter } from '../server/routers/_app';

export const trpc = createTRPCNext<AppRouter>({
  config() {
    return {
      links: [httpBatchLink({ url: '/api/trpc' })],
    };
  },
});

In any component, procedure calls become React Query hooks:

// pages/index.tsx
import { trpc } from '../utils/trpc';

export default function UsersPage() {
  const { data: users } = trpc.userList.useQuery();
  return <ul>{users?.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}

The return type of users is fully inferred from the server procedure. If the server’s userList procedure starts returning an email field, the client type updates automatically — no manual type sync required.

For Next.js App Router, the integration is @trpc/tanstack-react-query, which supports server components and the App Router data model. The setup differs from the Pages Router approach, and the Next.js Route Handler API Design post covers the App Router API patterns worth understanding before you wire tRPC into them.

When tRPC makes sense — and when it doesn’t

tRPC solves a specific problem: keeping a TypeScript client and a TypeScript server in sync without a separate schema layer. That precision is both its strength and its constraint.

Before you continue: Think about the consumers of your API. Are they TypeScript clients you control, living in the same repository? Or are there external consumers, other teams, or non-TypeScript clients in the picture?

Your situation What fits better Why
Full-stack TypeScript, one repository tRPC Type sharing is automatic; no schema overhead
Public API with external consumers REST + OpenAPI Consumers need docs and contracts, not TypeScript types
Multiple teams using different languages REST or GraphQL Type sharing only helps TypeScript consumers
Complex nested data with flexible queries GraphQL GraphQL resolvers compose better for arbitrary query shapes
A project with 2–3 endpoints and no growth expected Plain fetch + Zod tRPC setup costs more than it saves at this scale
Fine-grained HTTP caching requirements REST tRPC mutations are always POST; REST gives more control over cache semantics

The T3 Stack — Next.js, tRPC, Prisma, and Tailwind — popularised tRPC for greenfield TypeScript apps. That combination works well because types flow all the way from the database schema through the API layer to the UI component. If your project resembles that setup, tRPC pays for itself quickly.

If your API already works as REST and the main question is whether to migrate, the answer is almost certainly: don’t migrate everything. Add new endpoints as tRPC procedures and see if the workflow improves before committing to a full conversion.

Check before you commit

Check this before moving on

  • initTRPC.create() is called exactly once, and you export only the router and procedure helpers from that file — not the full t object
  • AppRouter is exported as export type AppRouter = typeof appRouter — a type, not a value
  • Your tsconfig.json has "strict": true — Zod input validation depends on strict null checking, and tRPC itself requires TypeScript 5.7.2 or newer
  • You’ve confirmed all current consumers of this API are TypeScript clients you control — if they’re not, type sharing doesn’t help them

The thing tRPC changes isn’t how HTTP works under the hood. Your API still runs over HTTP, queries still use GET, mutations still use POST, and everything is still JSON. What changes is where the source of truth for your API contract lives.

Without tRPC, the contract is implicit — the server returns something, the frontend assumes it knows the shape, and a mismatch hides until runtime. With tRPC, the contract is the TypeScript type, enforced at compile time. That’s a meaningfully different mental model for building frontend-to-backend communication in a TypeScript app, and it’s worth understanding whether it fits your project before you decide.

Sources