This episode builds a complete first tRPC API: a clean project structure, a router with query, mutation, and subscription, and the application of zod for modern input and output validation with type-safe schemas.

Enough theory — now you are going to build your first tRPC API. Episode 3 guides you step by step: arranging a clean project structure that can grow, defining a router with three types of procedures, and using zod to validate input and output in a modern way.
All the examples in this episode run without an HTTP server — we will use a caller to execute procedures directly. In episode 4, the same API will be served through a real server and consumed by a client.
tRPC projects are usually split by domain. The recommended structure for a project that will keep growing:
src/
├── server/
│ ├── trpc.ts
│ └── routers/
│ ├── index.ts
│ ├── user.ts
│ └── post.tsThe trpc.ts file becomes the foundation: this is where initTRPC is created and exported. Domain routers use this foundation, then everything is combined in routers/index.ts.
import { initTRPC } from "@trpc/server";
export const t = initTRPC.create();
export const router = t.router;
export const publicProcedure = t.procedure;With these exports, each router file only needs to import router and publicProcedure from trpc.ts — a single source of truth for the entire API.
A query is a procedure for reading data, equivalent to GET in REST. An example user router:
import { z } from "zod";
import { router, publicProcedure } from "../trpc";
const daftarUser = [
{ id: 1, nama: "Arman", role: "admin" },
{ id: 2, nama: "Budi", role: "user" },
];
export const userRouter = router({
list: publicProcedure.query(() => daftarUser),
byId: publicProcedure
.input(z.object({ id: z.number() }))
.query(({ input }) =>
daftarUser.find((u) => u.id === input.id),
),
});publicProcedure.query(() => ...) without .input() means the procedure accepts no arguments. For byId, .input(z.object({ id: z.number() })) ensures the client sends an id typed as number.
Mutations are used for operations that change state, equivalent to POST, PUT, or DELETE:
export const userRouter = router({
create: publicProcedure
.input(z.object({ nama: z.string().min(1) }))
.mutation(({ input }) => {
const user = { id: daftarUser.length + 1, ...input, role: "user" };
daftarUser.push(user);
return user;
}),
});Notice that the mutation output is the value returned by the resolver — its type is inferred straight down to the client.
Subscriptions use observables to stream real-time data, suitable for notifications or feeds:
import { observable } from "@trpc/server/observable";
clock: publicProcedure.subscription(() =>
observable<{ waktu: string }>((emit) => {
const timer = setInterval(() => {
emit.next({ waktu: new Date().toISOString() });
}, 1000);
return () => clearInterval(timer);
}),
),All domain routers are combined into one root router:
import { userRouter } from "./user";
export const appRouter = router({
user: userRouter,
});
export type AppRouter = typeof appRouter;Besides input, zod can also validate output. This is important so that data leaking to the client matches the contract, for example hiding sensitive fields:
import { z } from "zod";
const UserPublic = z.object({
id: z.number(),
nama: z.string(),
});
export const userRouter = router({
profile: publicProcedure
.input(z.object({ id: z.number() }))
.output(UserPublic)
.query(({ input }) => {
const user = daftarUser.find((u) => u.id === input.id);
if (!user) throw new Error("User tidak ditemukan");
return { id: user.id, nama: user.nama, role: user.role };
}),
});The resolver above returns an object with a role field, but because .output(UserPublic) sets a contract of only id and nama, tRPC will validate and reject responses that do not match the schema. We will cover proper error handling in episode 5.
Without an HTTP server, we can test the entire router directly through a caller:
import { createCallerFactory } from "@trpc/server";
import { appRouter } from "./routers";
const createCaller = createCallerFactory(appRouter);
const caller = createCaller({});
const user = await caller.user.byId({ id: 1 });
const created = await caller.user.create({ nama: "Citra" });
console.log(user, created);The call caller.user.byId({ id: 1 }) executes the procedure complete with input and output validation, without involving the network. If you send { id: "satu" }, zod will reject it before the resolver runs.
Episode 3 teaches you to build your first tRPC API: an organized project structure, a router with query, mutation, and subscription, input and output validation with zod, and quick testing through a caller.
Key takeaways:
trpc.ts foundation from domain routers to keep the project clean..input() validates arguments; .output() validates responses.AppRouter type.In the next episode, episode 4, you will run the tRPC server and consume the API from a client — setting up the server with Express or the Next.js App Router, creating a tRPC client on the frontend, and seeing type-safe autocompletion on your first request.