Engineering Deep Dive • 18 min read • 10/14/2026

tRPC in a NestJS Monorepo: A Type-Safe Contract from Router to Hook

Bài này có bản tiếng Việt. Đọc bản tiếng Việt →

Nguyen Hai Trinh

Nguyen Hai Trinh • Senior Software Engineer @ SmileViet

Network cables in a patch panel
Photo: Airman Holden Faul · Public domain

In the REST world, renaming a field is a two-player sport: the backend renames, the frontend chases. The winner is whoever runs CI last. A tie means both sides ship undefined to production, and a pleasant afternoon goes to forensics across three repos.

hr-tool — my Nx monorepo with a NestJS backend, a React 19 frontend, and a React Native mobile app — took a different road: there is no REST controller at all. Every endpoint goes through tRPC, and the frontend’s types are generated from the backend’s router files themselves. If the backend renames a field before the frontend catches up, TypeScript turns red before anyone manages to build. This post follows an API “contract” through the whole repo: from a router file on the backend to the hook a component calls as useJobList().

What tRPC is: calling remote functions without dropping types on the way

Short version: tRPC = TypeScript RPC. You call a function running on the server as if it were local, and the input/output types are inferred end-to-end from backend to frontend. No separate schema file. No codegen step. No profession called “keeping DTOs in sync by hand”.

The difference starts with how you describe an API. REST describes APIs with nouns: resources plus HTTP verbs — GET /jobs?page=2, PATCH /jobs/:id. tRPC describes APIs with named functions: job.list({ page: 2 }), job.updateStage({ id, stage }). The former needs a translation layer (controllers, DTOs, client wrappers) to turn a URL into a typed call; the latter is a typed call from the start.

The three building blocks

  • Procedure — one endpoint. Each procedure has an input (a Zod schema) and a resolve (a handler receiving { ctx, input }). The output needs no declaration — its type is inferred from the handler’s return value (want stricter output validation? add an .output() schema). Declaring .query() or .mutation() decides whether it reads or writes — and that matters on the frontend, shortly.
  • Router — procedures grouped by domain via router({ list, markAsRead }), then merged into a tree with router({ job: jobRouter, auth: authRouter }). That tree is your API map: the names on it are the URL paths.
  • Context — an object created fresh per request: user, companyId, req/res. It flows into every procedure; auth and multi-tenancy live here.

What a procedure looks like on the wire

tRPC isn’t magic — it’s still HTTP. A client call to useJobList({ page: 2 }) becomes a request to /trpc/job.list with the input serialized. Queries fired within the same frame are batched into a single GET with inputs in the query params; mutations batch into a POST. The response is JSON with result.data — or an errors array carrying a TRPC code. HTTP is still there; you just never have to think about it.

sequenceDiagram
  participant H as useJobList({ page: 2 })
  participant L as httpBatchLink
  participant R as /trpc/job.list
  participant Z as zod schema
  participant Svc as jobService

  H->>L: input + context headers
  L->>R: GET batch (input serialized)
  R->>Z: parse input
  Z-->>R: validated input, correct type
  R->>Svc: resolve({ ctx, input })
  Svc-->>R: data
  R-->>H: result.data — type inferred from return

The error model: codes with meaning, not scattered strings

Procedures throw TRPCError with a code from a fixed set — BAD_REQUEST, UNAUTHORIZED, FORBIDDEN, NOT_FOUND, INTERNAL_SERVER_ERROR… The HTTP adapter maps each code to its status (401, 403, 404…). No arbitrary strings for the client to guess. Want to shape the outgoing error (say, hide stacks in production)? There’s errorFormatter — you’ll meet it again in the procedure-chain section.

How the type inference actually works

This is the key to the whole system. The client needs to know exactly one thing: the router’s type:

export type AppRouter = ReturnType<typeof createAppRouter>;

From that single type, tRPC builds a fully type-safe proxy client: typing trpc.job. autocompletes every procedure on the job router, each input is the Zod schema parsed into a TS type, each output is the handler’s return type. The server changes, the type changes — every stale call site turns red immediately. The type isn’t documentation accompanying the code; it is the code.

If you ever need inputs/outputs as standalone types, tRPC ships inferRouterInputs<AppRouter> / inferRouterOutputs<AppRouter> helpers. hr-tool never needed them — everything flows through hooks and useUtils, and the types follow along.

And the question everyone asks: what’s the real difference between query and mutation beyond the name? It maps directly onto React Query semantics: .query() for idempotent, cacheable operations — producing useQuery with cache, staleTime, refetch; .mutation() for writes — producing useMutation with manual invalidation. That’s why the auto-generated hook naming table in the next section distinguishes the two at parse time.

The core difference from REST is where the types live:

flowchart LR
  subgraph REST["REST + OpenAPI"]
    D["DTO + controller"] --> S["OpenAPI spec"]
    S --> G["codegen or hand-written types"]
    G --> C1["FE fetch + parse at runtime"]
  end
  subgraph TRPC["tRPC"]
    Z["router + zod"] --> T["AppRouter type"]
    T --> C2["FE calls directly, types inferred"]
  end

On the REST side, the schema describing the API exists in parallel with the code: the spec, the DTOs, the FE types — three copies of one truth, drifting on the release schedules of three different groups of people. In tRPC there’s one copy: the router file. Types don’t need to be “kept in sync” — they are the code itself.

Next to the two familiar alternatives:

REST + OpenAPIGraphQLtRPC
Type sourcespec + hand-written DTOsschema + codegeninference from the router
Validationmanual / DTOresolver’s jobZod built-in
Non-TS clientsOKOKNo
Over-fetchYesLittleNone — a procedure returns exactly what it defines
Best fitPublic APIs, many clientsHeavy GraphQL ecosystemAll-TS, same team, monorepo

Why hr-tool chose tRPC

Four reasons, weighted:

  1. Every client is TS — web React 19, mobile React Native, no non-TS consumer anywhere. tRPC’s biggest advantage applies at 100%, not half.
  2. Monorepo, one source of truth — BE and FE live in one repo and release together. tRPC’s classic weakness is that FE/BE must release together; for hr-tool that’s not a drawback — it’s the design.
  3. React Query comes built in — tRPC doesn’t invent data-fetching; it stands on TanStack Query. Caching, invalidation, retries: all existing standards, nothing new to learn.
  4. Small team, high speed — one person changes backend and frontend in the same day. The “meetings, schema review, spec publishing” ceremony is overhead for large organizations; here it’s pure friction.

And to be fair: when not to use tRPC — you need to expose an API to partners or third parties, you have Python/Java/Go clients, or BE and FE belong to two teams with independent releases. In those cases OpenAPI or GraphQL is the right choice, and tRPC becomes a broken bridge between two shores. It’s a tool for all-TS monorepos, not a religion.

The big picture: where the contract flows

Three layers of the monorepo touch the API:

flowchart LR
  subgraph BE["apps/api — NestJS"]
    CTX["7 bounded contexts"]
    RF["router factory, co-located"]
    APP["createAppRouter"]
    EX["Express adapter /trpc"]
  end
  subgraph PKG["packages"]
    T["api-types — AppRouter .d.ts"]
    H["api-client — generated hooks"]
  end
  subgraph FE["apps/web + apps/mobile"]
    F["feature folders"]
    Q["TanStack Query"]
  end

  CTX --> RF --> APP --> EX
  APP -. "type" .-> T
  RF -. "ts-morph parse" .-> H
  T --> F
  H --> F
  F --> Q

The part worth staring at: there are two channels flowing from backend to frontend. The type channel (dotted arrows) runs at compile time — AppRouter re-exported through @hr-tool/api-types. The hook channel is also dotted, but it isn’t type inference — it’s a script that parses the router files and prints code. Those two channels are the most interesting part of this post; they get their own section.

How tRPC lives inside NestJS

tRPC isn’t middleware you “install into” NestJS magically. It’s an express handler, and NestJS is express underneath. So TrpcModule implements NestModule.configure() and mounts the adapter on /trpc:

consumer.apply(
  trpcExpress.createExpressMiddleware({
    router: createAppRouter(/* services */),
    createContext: ({ req, res }) => createContext(req, res),
    onError: ({ error, path }) => {
      const cause = error.cause?.message ?? error.message;
      logger.error(`Error in ${path}: ${error.code} — ${cause}`);
    },
  })
).forRoutes('/trpc');

No controllers. No DTOs. No Nest interceptors. tRPC owns the entire /trpc/* path, and Nest gets demoted to a DI container — the one role it plays well. And a role it doesn’t play small: createAppRouter receives 33 services as parameters, injected through TrpcModule’s constructor from the six bounded-context aggregator modules.

That’s the most expensive part of this approach, and I’ll say it plainly: that 33-service parameter list is a debt with interest. Every new module adds one line in two places — the constructor and the createAppRouter call. In exchange, it’s the single place in the codebase that knows how the routers wire together. One file holding all the wiring sounds terrifying, but it beats ten scattered files each knowing a piece.

Context: one authentication path for web and mobile

The tRPC context is created fresh per request, and it’s where “who is calling” gets decided. hr-tool has two clients: the web reads a JWT from an httpOnly cookie, mobile sends Authorization: Bearer. One context serves both — sparing me the joy of maintaining two copies of auth logic that say the same thing:

const token = tokenFromHeader ?? tokenFromCookie;

const payload = jwt.verify(token, deps.jwtSecret) as JwtPayload;

// logout revokes tokens via a denylist keyed by jti
if (payload.jti && (await getTokenDenylist().isRevoked(payload.jti))) {
  throw new Error('Token revoked');
}

const userEntity = await deps.userRepository.findOne({
  where: { id: payload.sub },
});

Three details that matter more than the “verify JWT and move on” version:

  • Token denylist by jti: access tokens live 7 days. Without the denylist, a stolen token survives logout by a week — logout becomes a ceremony. Every request pays one Redis check; that’s the price of logout meaning something.
  • No token logging: the catch block logs only "JWT verification failed" — no token, no stack, because logs have an excellent track record of leaking credentials and PII.
  • Company mode rides with the user: enterprise or headhunt drives UI and permissions in many places, so the context loads it up front instead of every procedure querying it and occasionally forgetting.

The procedure chain: three layers, one job each

trpc.ts doesn’t export a single procedure — it exports a chain of layers, each solving one class of error:

// Layer 0: NestJS HttpException → TRPCError code
const baseProcedure = t.procedure.use(httpExceptionMiddleware);

// Layer 1: authentication required
export const protectedProcedure = baseProcedure.use(({ ctx, next }) => {
  if (!ctx.user) throw new TRPCError({ code: 'UNAUTHORIZED' });
  return next({ ctx: { ...ctx, user: ctx.user } });  // user is no longer null
});

Layer 0 patches a classic mistake when bolting Nest onto tRPC: Nest services throw HttpExceptions (401, 403, 404…), tRPC doesn’t recognize them and wraps everything as 500 INTERNAL_SERVER_ERROR. A wrong password returning 500 “Internal server error” — wrong semantics, and useless to whoever is debugging at 2 AM.

The layer above that is a procedure factory — the front door of the collaborator (external recruiter) portal:

export const createCollaboratorProcedure = (resolver: CollaboratorResolver) =>
  protectedProcedure.use(async ({ ctx, next }) => {
    if (!isCollaboratorRole(ctx.user.role)) throw ...FORBIDDEN;
    const collaborator = await resolver.resolveActiveByUser(
      ctx.user.id, ctx.user.companyId
    );
    if (!collaborator) throw ...FORBIDDEN;
    return next({ ctx: { ...ctx, collaboratorId: collaborator.id } });
  });

The elegant part: the resolver is injected, not imported — trpc.ts keeps a minimal interface and never depends back on the referral context. And the collaboratorId attached to ctx is the only trusted identity for scoping collaborator data. Handlers never accept a collaboratorId from the client — because clients, as always, are not to be trusted.

At the outermost layer sits the errorFormatter: on prod-like environments (production, staging) it strips stack from the payload and replaces the message of unhandled errors with "Internal server error". Dev keeps everything for debugging. Production doesn’t need to see what your stack trace looks like.

Router factories: the contract lives next to the business logic

Every module has a router file in the same folder as its service — the repo’s ADR-004 calls them co-located routers:

const createNotificationRouter = (
  notificationService: NotificationService,
  systemLogService: SystemLogService
) =>
  router({
    list: protectedProcedure
      .input(z.object({
        unreadOnly: z.boolean().optional().default(false),
        page: z.number().min(1).default(PAGINATION.DEFAULT_PAGE),
        limit: z.number().min(1).max(PAGINATION.MAX_PAGE_SIZE)
               .default(PAGINATION.DEFAULT_LIMIT),
      }).optional())
      .query(async ({ ctx, input }) => {
        return notificationService.list({
          userId: ctx.user.id,
          companyId: ctx.user.companyId,   // multi-tenant scope
          ...input,
        });
      }),

    markAsRead: protectedProcedure
      .input(z.object({ id: z.string().uuid() }))
      .mutation(async ({ ctx, input }) => { /* ... */ }),
  });

The router is a factory taking services as parameters — it never imports them itself. The only place that assembles them is createAppRouter:

export const createAppRouter = (/* services */) =>
  router({
    health: healthRouter,
    auth: createAuthRouter(authService, systemLogService),
    job: createJobRouter(jobService, systemLogService),
    // ...
  });

export type AppRouter = ReturnType<typeof createAppRouter>;

URLs come out as /trpc/notification.list, /trpc/job.updateStage. The Zod schema validates input and serves as the type source — one declaration doing two jobs, an efficiency ratio rare in this industry.

Frontend: two channels, and why there must be two

This is the part most tRPC introductions skip, because their demo is a fullstack Next.js app in one package. In an Nx monorepo where the Nest backend builds separately, cross-package type inference doesn’t work like the ads say.

Channel 1 — types: packages/api-types doesn’t infer directly from apps/api source. It re-exports and then pre-builds to .d.ts via tsc + tsc-alias, and apps/web typechecks against that built file:

// packages/api-types/src/index.ts — 9 lines that decide the pipeline
export type { AppRouter } from '../../../apps/api/src/trpc/routers';

If the web read types directly from the api’s TS source, every web typecheck would drag the backend’s whole dependency graph in — slow and brittle, the two qualities you least want in a pipeline. Pre-building cuts that tail: the web only sees a stable .d.ts, and rebuilding it is wired into Nx’s build graph.

Channel 2 — hooks: packages/api-client doesn’t just re-export createTRPCReact. The generate-hooks-auto.ts script, run via yarn generate:hooks, uses ts-morph to parse every router file under apps/api/src, reads whether each procedure is a query or a mutation, and generates React Query hooks from a naming-convention table:

The backend router file goes through ts-morph parsing and the naming map to generate hooks, alongside api-types pre-built .d.ts — two contract channels from BE to FE

Why not just use the inference of trpc.createTRPCReact<AppRouter> for hooks? Because inference tells you how to call, not a hook name that reads like a designed API. trpc.job.list.useQuery() works — and reads like plumbing. useJobList() reads like someone cared about their colleagues. The naming table has per-router overrides (cvMatching.createSession → useCreateMatchingSession), because a generic convention never covers every natural name.

One client-config detail worth noting: the web uses httpBatchLink — queries issued in the same frame get batched into a single HTTP request. A screen using five endpoints doesn’t need five conversations with the server, as if it were 2005.

How the FE actually consumes the hooks: a real page

Telling the generator story without showing the receiving end is only half the story. Here’s real code from JobsPage.tsx — the jobs list page:

import {
  useJobList, useDeleteJob, usePartnerList, useInvalidation,
} from '@hr-tool/api';

// the query only runs in headhunt mode
const { data: partnersData } = usePartnerList(
  { limit: PAGINATION.MAX_PAGE_SIZE },
  { enabled: isHeadhuntMode }
);

const deleteJob = useDeleteJob();

const handleDelete = async (id: string) => {
  try {
    await deleteJob.mutateAsync({ id });
    toast.success(t('toasts.job.deleteSuccess', { title }));
    await invalidate.job();
  } catch {
    toast.error(t('toasts.job.deleteFailed'));
  }
};

Three things worth reading closely:

  • The second parameter is standard React Query options. enabled: isHeadhuntMode keeps the query dormant until the condition holds. The generator has to handle this correctly or the hook is demo-ware — and it does, because the hook is just a procedure reference.
  • mutateAsync instead of mutate — it can be awaited, errors are caught in try/catch, and the success and error toasts live next to each other instead of scattered across callbacks.
  • invalidate.job() — the component has no idea that employee, department, and stats are also caching stale numbers related to this action. The invalidation map owns that; the component just calls.

And what is a generated “hook”, really? Open the generated file:

export const useJobList = trpc.job.list.useQuery;

Yes, really. The generator wraps nothing — it renames a procedure reference, carrying the input types from the backend’s Zod schema intact. Slightly anticlimactic, and that’s the good part: there’s no extra abstraction layer to learn. Want enabled, onError, staleTime? All standard React Query options the underlying hook accepts. And when you pass a wrong input type — say page: "1" instead of a number — TypeScript turns red right where you’re typing, without waiting for runtime to break the news.

Invalidation: the layer no framework does for you

tRPC + React Query solves fetching. It doesn’t answer “which queries does this mutation dirty?”. queryKeys.ts adds one invalidation map keyed by module, built on trpc.useUtils():

employee: async () => {
  await Promise.allSettled([
    utils.employee.invalidate(),
    utils.department.invalidate(),
    utils.position.invalidate(),
    utils.stats.invalidate(),
  ]);
},

Create an employee → invalidate employee, department, position, and stats together — because the dashboard counting headcount is also caching a stale number. Components just call invalidate.employee(), oblivious to the list’s existence. That map is where “knowing which data relates to which data” gets centralized — and it’s only easy to write when every query key comes from the same source.

The price you pay

No architecture is free. This is the invoice after nearly a year of production:

ProblemRealityHow it’s handled
Forgetting generate:hooks after a router changeThe new hook doesn’t exist; TS errors immediatelyIn the checklist; CI typecheck blocks
createAppRouter receives 33 servicesLong parameter list; every new module adds 2 linesAccepted — it’s the single wiring point
Non-TS clients can’t call the APINo OpenAPI schema for SwaggerNot needed — every client (web, mobile) is TS
FE/BE always release togetherA type change forces the web build to followIntentional, by monorepo design
Router tests must cover the role matrixEvery endpoint × every roleRouter specs at 100%, templates exist

Swagger deserves its own line: it’s the world’s greatest API documentation — right up until it goes stale by one version. In an all-TypeScript monorepo, the always-correct version of Swagger is the compiler. tRPC puts the contract into the compiler and charges two yarn commands in the pipeline. On this market, that’s a rare bargain.

Closing

Adding a new endpoint in hr-tool follows the checklist in ARCHITECTURE.md: service, router factory, tests, wire into the aggregator, yarn generate:hooks. The longest step is almost always writing the tests — the client code, meanwhile, doesn’t exist to be written.

And if you forget generate:hooks? TypeScript reminds you the moment you type the hook’s name, before CI even gets a word in. That’s the entire protection mechanism of this contract: it lives in the compiler, not in a Confluence page last updated last quarter. The compiler isn’t polite, but it never lies.

Need a developer for a contract or freelance project?

I take on fullstack projects — Go backends, React/Next.js frontends, or both ends. Send a few lines about the scope and timeline and I'll reply within 48 hours.