This episode breaks down tRPC's architecture: how TypeScript infers types from the server to the client, the role of router, procedure, input, output, and caller, as well as the adapter layer for HTTP, WebSocket, and serverless transport along with the proxy client and React client.

Episode 2 dives into the most important part before writing code: tRPC's architecture. You already know why tRPC exists; now it is time to understand how it works behind the scenes — how types flow from the server to the client, what components make up an API, and how tRPC connects to HTTP, WebSocket, or serverless environments.
After this episode, terms like router, procedure, input, output, caller, and links will no longer be foreign. This is the vocabulary used throughout the whole series.
The key to tRPC is TypeScript inference. When you define a router on the server, its complete type — procedure names, input shapes, and output shapes — is stored in an AppRouter type. The client uses this type as a type argument when creating a proxy client, so the editor knows exactly what is available.
Notice the flow in one simple diagram:
server router → type AppRouter → client proxy → autocompletionThere is no extra step like codegen. Types are simply read from the server definition, and TypeScript keeps them in sync every time a procedure changes. This is why tRPC is called zero-codegen and zero-schema-duplication.
A tRPC API is composed of three layers:
This transport layer is pluggable, so the same router can be served by a standalone server, Express, Fastify, Next.js, or serverless without changing the procedure definitions.
Let's look at the core components in one real example:
import { initTRPC } from "@trpc/server";
import { z } from "zod";
const t = initTRPC.create();
export const appRouter = t.router({
user: t.router({
byId: t.procedure
.input(z.object({ id: z.number() }))
.query(({ input }) => ({ id: input.id, nama: "Arman" })),
create: t.procedure
.input(z.object({ nama: z.string().min(1) }))
.mutation(({ input }) => ({ id: 2, nama: input.nama })),
}),
});
export type AppRouter = typeof appRouter;The important parts:
t.router({...}) defines a router, and routers can be nested like user.t.procedure is the basic building block; it ends with .query(), .mutation(), or .subscription()..input(schema) validates and types the incoming input.In the code above, appRouter.user.byId.query({ id: 1 }) will return an object typed as { id: number; nama: string }.
A router knows nothing about protocols. An adapter connects it to the outside world:
createHTTPServer for a standalone server, createExpressMiddleware for Express, fetchRequestHandler for Next.js and serverless.wsServer (called applyWSSHandler in v10) for real-time subscriptions.The consequence is big: the procedure code stays the same, only the adapter changes depending on the deployment. Episodes 10 and 16 will discuss both in detail.
On the client side, tRPC provides two ways to access the same router:
import { createTRPCProxyClient, httpBatchLink } from "@trpc/client";
import { createTRPCReact } from "@trpc/react-query";
const trpc = createTRPCProxyClient<AppRouter>({
links: [httpBatchLink({ url: "http://localhost:3000/trpc" })],
});
const api = createTRPCReact<AppRouter>();createTRPCProxyClient is used outside React — terminals, scripts, or vanilla JS.createTRPCReact generates hooks like api.user.byId.useQuery() inside React.Both accept AppRouter as a type argument, so autocompletion and type-safety fully apply on the client side.
Sometimes you need to call a procedure from within the server itself — for example from a cron job, a webhook handler, or when preparing data on the server. This is where caller plays its role:
import { createCallerFactory } from "@trpc/server";
const createCaller = createCallerFactory(appRouter);
const caller = createCaller({});
const user = await caller.user.byId({ id: 5 });
console.log(user.nama);createCallerFactory(appRouter) produces a caller function that executes procedures without an HTTP round-trip, but still runs middleware and validation. This pattern is important for internal services and will be used again in episode 17 for testing.
Tip
Understand the difference: a resolver is a definition, a caller is an execution. The caller executes a procedure as if it came from a client, complete with middleware and input validation.
Episode 2 gives you a map of tRPC's architecture: types flow from the server router to the client through inference, procedures are composed of input, resolver, and output, transport is provided by pluggable adapters, and the caller enables invocation from within the server.
Key takeaways:
.input() validates and types the input; the output is inferred from the resolver.createTRPCProxyClient for non-React; createTRPCReact for hooks.caller executes procedures inside the server without a round-trip.AppRouter is exported as a type so the client can infer it.In the next episode, episode 3, we will go straight to building your first tRPC API — arranging the project structure, defining a router with query, mutation, and subscription, and applying zod validation on both input and output.