← Back to articles
Backend tRPCTypeScriptReactNext.js

tRPC: End-to-End Type Safety Without Codegen

· 5 min read

The gap between a typed frontend and a typed backend is where most runtime bugs are born: you define a shape on the server, redefine it on the client, and the two drift apart the moment someone ships a change. REST leaves you writing that contract twice and trusting that both copies stay in sync. GraphQL closes the gap but drags in a schema language, a build step and codegen to keep types aligned. tRPC removes the contract entirely. You write your API as plain TypeScript functions, and the client infers their inputs and outputs straight from the server code, with no schema language, no generated types and no build step in between.

Procedures and routers: the server as functions

A tRPC API is a tree of procedures, each one a function with a declared input and a query or mutation body. You group them into a router, and that router becomes the single source of truth for the entire API surface.

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

const t = initTRPC.create();

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

  createUser: t.procedure
    .input(z.object({ name: z.string(), email: z.string().email() }))
    .mutation(({ input }) => saveUser(input)),
});

export type AppRouter = typeof appRouter;

query reads, mutation writes, and the exported AppRouter type carries the shape of every procedure. That single typeof export is the whole contract the client will consume.

Input validation with Zod

The .input() call accepts any validator, and Zod is the natural fit because it produces both a runtime guard and a static type from one declaration. A malformed request is rejected before your handler runs, and input arrives already typed inside it.

export const postRouter = t.router({
  list: t.procedure
    .input(
      z.object({
        limit: z.number().min(1).max(100).default(20),
        cursor: z.string().optional(),
      })
    )
    .query(({ input }) => {
      // input.limit is a number, input.cursor is string | undefined
      return getPosts(input);
    }),
});

There is no separate DTO to keep in sync and no manual parsing. The validator is the type, so the value that reaches your logic has already passed its contract. This Zod schema is a per-procedure input guard, not an API schema language: nothing generates client types from it, and the client contract still comes from TypeScript inference alone.

Type inference on the client

The client is built from the AppRouter type alone, imported with import type so none of the server code ships to the browser. Every procedure is then reachable with full autocomplete and its return type inferred end to end.

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

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

const user = await trpc.userById.query({ id: '1' });
// user is typed from the server return, no codegen step

Rename a field on the server and the client stops compiling in the same breath. The type error becomes the contract test, caught at build time instead of in production.

React Query integration

On the frontend, tRPC wraps TanStack Query so every procedure turns into a fully typed hook. You get caching, refetching and mutation state for free, keyed automatically by the procedure and its input.

import { trpc } from './trpc';

function UserProfile({ id }: { id: string }) {
  const { data, isLoading } = trpc.userById.useQuery({ id });

  if (isLoading) return <Spinner />;
  return <h1>{data.name}</h1>;
}

data is typed as the procedure’s return value, and the query key is derived from the input, so cache invalidation stays correct without you writing a single string key by hand.

Middleware and protected procedures

Cross-cutting concerns like authentication belong in middleware, which runs before the procedure and can refine the context that flows into it. Building a protectedProcedure once gives every guarded route a narrowed, non-null user without repeating the check.

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

const isAuthed = t.middleware(({ ctx, next }) => {
  if (!ctx.user) throw new TRPCError({ code: 'UNAUTHORIZED' });
  return next({ ctx: { user: ctx.user } });
});

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

Because next() returns a refined context, TypeScript knows ctx.user is defined in any procedure built on protectedProcedure. The authorization rule and its type guarantee are the same line of code.

Adapters: where tRPC runs

tRPC is not tied to a single server. One router mounts onto whatever runtime you use through an adapter, from a Next.js route handler to a standalone Node server or an edge function, without touching the procedures themselves.

import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter } from '@/server/router';

const handler = (req: Request) =>
  fetchRequestHandler({
    endpoint: '/api/trpc',
    req,
    router: appRouter,
    createContext: () => ({ user: null }),
  });

export { handler as GET, handler as POST };

The same appRouter powers a serverless function here and a long-running server elsewhere. You write the API once and choose the transport later.

Conclusion

tRPC is the smallest possible distance between a typed server and a typed client: no schema, no generated code, just TypeScript inference doing the work. It pays off most on full-stack projects where one team owns both ends and a shared type is worth more than a language-agnostic contract. When you need to expose a public API to consumers you do not control, REST or GraphQL still earn their place. Inside a TypeScript monorepo, tRPC turns the client-server boundary from a source of runtime bugs into something the compiler simply enforces.