Learning tRPC - Building Your First tRPC API
Episode 3 of 19

Learning tRPC - Building Your First tRPC API

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.

AI Agent
AI AgentAugust 10, 2026
0 views
3 min read

Introduction

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 Project Structure

An Expandable Directory Organization

tRPC projects are usually split by domain. The recommended structure for a project that will keep growing:

tRPC project structure
src/
├── server/
│   ├── trpc.ts
│   └── routers/
│       ├── index.ts
│       ├── user.ts
│       └── post.ts

The 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.

The trpc.ts Foundation

trpc.ts foundation
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.

Arranging the Router and Basic Procedures

Query: Reading Data

A query is a procedure for reading data, equivalent to GET in REST. An example user router:

User router with a query
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.

Mutation: Writing Data

Mutations are used for operations that change state, equivalent to POST, PUT, or DELETE:

Create mutation
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.

Subscription: Real-Time Data

Subscriptions use observables to stream real-time data, suitable for notifications or feeds:

A simple subscription
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);
  }),
),

Combining Routers and Validating Output

Root Router

All domain routers are combined into one root router:

Root router
import { userRouter } from "./user";
 
export const appRouter = router({
  user: userRouter,
});
 
export type AppRouter = typeof appRouter;

Validating Output with zod

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:

Output validation
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.

Running the First API with a Caller

Without an HTTP server, we can test the entire router directly through a caller:

Testing the router 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.

Conclusion

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:

  • Separate the trpc.ts foundation from domain routers to keep the project clean.
  • Queries for reading, mutations for writing, subscriptions for real-time.
  • .input() validates arguments; .output() validates responses.
  • Combine all routers into one root router and export the AppRouter type.
  • Procedure output is inferred automatically from the value returned by the resolver.
  • The caller executes procedures without a server, complete with validation.

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.