Skip to content

What it is

tRPC gives you end-to-end type safety between a TypeScript server and client with no code generation, no schema files and no build step.

Define procedures on a router with input validation. Export the router's type, import it on the client, and every call is autocompleted and type-checked against the real implementation.

Installation

npm install @trpc/server @trpc/client

Getting started

The smallest useful thing you can do with it, and what each part means.

Router and typed client
// server/router.ts
import { initTRPC } from '@trpc/server';
import { z } from 'zod';

const t = initTRPC.create();

export const appRouter = t.router({
  bookById: t.procedure
    .input(z.object({ id: z.string() }))
    .query(({ input }) => store.find(input.id)),

  createBook: t.procedure
    .input(z.object({ title: z.string().min(1), year: z.number() }))
    .mutation(({ input }) => store.create(input)),
});

// Only the *type* crosses the boundary — no server code is bundled.
export type AppRouter = typeof appRouter;
The `export type` is the whole mechanism. `import type` is erased at compile time, so the client gets full type information and zero server bytes.
Calling it from the client
import { createTRPCClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from '../server/router';

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

const book = await client.bookById.query({ id: '42' });
// book is fully typed from the server's return value

// client.bookById.query({ id: 42 })  // compile error: id must be a string
Rename a field on the server and every client call site becomes a compile error immediately — no regeneration step, no drift window.

Advanced usage

Where the library earns its place over a simpler alternative.

Context, middleware and protected procedures
const t = initTRPC.context<{ user?: User }>().create();

const isAuthed = t.middleware(({ ctx, next }) => {
  if (!ctx.user) throw new TRPCError({ code: 'UNAUTHORIZED' });
  // Narrow the context type for everything downstream.
  return next({ ctx: { user: ctx.user } });
});

export const protectedProcedure = t.procedure.use(isAuthed);

export const appRouter = t.router({
  me: protectedProcedure.query(({ ctx }) => ctx.user), // ctx.user is non-optional
});
The middleware narrows the context type as well as guarding at runtime, so protected procedures cannot accidentally treat the user as possibly undefined.

Errors and fixes

The failures you are most likely to hit, and what actually resolves them.

Client types resolve to any
The AppRouter type is not resolving. Ensure client and server share a tsconfig path or the same monorepo package, and that the server actually exports the type.
TRPCError is not surfacing the right status
Use the documented codes (UNAUTHORIZED, NOT_FOUND, BAD_REQUEST) — tRPC maps those to HTTP statuses; arbitrary throws become 500.

Best practices

  • Import the router with `import type` so no server code leaks into the client bundle.
  • Validate every input with a schema; tRPC types the call but does not check the payload for you.
  • Use httpBatchLink to collapse simultaneous calls into one request.
  • Only choose tRPC when you control both ends and both are TypeScript — it is not for public APIs.

Background

Why it exists, and what it was reacting to.

Created by Alex Johansson, tRPC exploits the fact that when both ends are TypeScript, the types already exist — the client can import the server's router type directly, making a code-generation step unnecessary.