Your backend is
just functions.
tRPC gives you typed functions your frontend calls directly. Like an internal SDK that writes itself.
What is Zod?
A schema validation library for TypeScript. One definition gives you runtime validation and a TypeScript type — no duplication.
The problem it solves
// You write the type… interface TaskInput { title: string; priority: "LOW" | "MEDIUM" | "HIGH"; } // …then write validation AGAIN function validate(data: unknown) { if (typeof data.title !== "string") throw new Error("bad title"); if (data.title.length === 0) throw new Error("empty"); // ...repeat for every field 😩 } // Two sources of truth. They WILL drift.
With Zod
import { z } from "zod"; // One definition = validation + type const TaskInput = z.object({ title: z.string().min(1), priority: z.enum([ "LOW", "MEDIUM", "HIGH" ]), }); // Extract the TS type for free type TaskInput = z.infer< typeof TaskInput >; // → { title: string; priority: "LOW" | … } // Runtime check: TaskInput.parse(data); // throws if bad TaskInput.safeParse(data); // returns result
Zod building blocks
You'll use these every time you write a tRPC procedure. There are more in the docs — these are the ones that matter today.
Primitives & modifiers
// Strings z.string() // any string z.string().min(1) // non-empty z.string().email() // valid email z.string().url() // valid URL // Numbers z.number() // any number z.number().int().positive() // > 0 integer z.number().min(0).max(100) // range // Others z.boolean() // true/false z.date() // Date object // Modifiers — work on any type z.string().optional() // string | undefined z.string().nullable() // string | null z.string().default("hi") // defaults if absent
Composing schemas
// Enums — exact string unions z.enum(["LOW", "MEDIUM", "HIGH"]) // → "LOW" | "MEDIUM" | "HIGH" // Objects — your main building block const CreateTask = z.object({ title: z.string().min(1), priority: z.enum(["LOW", "MEDIUM", "HIGH"]), projectId: z.string(), }); // Extend an existing schema const UpdateTask = z.object({ id: z.string(), title: z.string().min(1).optional(), priority: z.enum(["LOW", "MEDIUM", "HIGH"]) .optional(), }); // Arrays z.array(z.string()) // string[] z.string().array() // same thing
Why this matters for tRPC
Contract-first procedures use .input(zodSchema) and .output(zodSchema). Zod validates both ends at runtime while TypeScript infers both types automatically.
The tRPC vocabulary
Six terms. Once you know them, every tRPC codebase reads the same way.
Procedure
A single API endpoint — a function the client can call. Can be a query, mutation, or subscription.
Router
A group of related procedures under one namespace. Like taskRouter containing getAll, create, etc.
Query
A procedure that reads data. Think GET. Uses .query(). Called with useQuery() on the client.
Mutation
A procedure that writes data. Think POST/PUT/DELETE. Uses .mutation(). Called with useMutation().
Context
Shared state every procedure can access — database connection, session, headers. Created once per request.
Middleware
Runs before a procedure. Can check auth, log, or add data to context. Chains with .use().
REST mental bridge
GET /api/tasks → api.task.getAll.useQuery(). POST /api/tasks → api.task.create.useMutation(). Same thing — but the compiler checks every call.
How the pieces fit together
Context — shared state
// Created once per request export const createTRPCContext = async (opts: { headers: Headers }) => { return { db, // Prisma client ...opts, // headers, etc. }; }; // Every procedure gets ctx automatically: // ctx.db → query the database // ctx.headers → read request headers
Middleware — intercept & extend
// Runs BEFORE the procedure const isAuthed = t.middleware(({ ctx, next }) => { if (!ctx.session?.user) throw new TRPCError({ code: "UNAUTHORIZED", }); // Pass enriched context downstream return next({ ctx: { user: ctx.session.user }, }); }); // Create a reusable procedure base export const protectedProcedure = t.procedure.use(isAuthed);
Router — group procedures
import { taskRouter } from "./routers/task"; import { projectRouter } from "./routers/project"; export const appRouter = createTRPCRouter({ task: taskRouter, project: projectRouter, }); // Client calls become: // api.task.getAll.useQuery() // api.project.create.useMutation() // ↑ namespace comes from the key name
Query vs Mutation
// QUERY — reads data. Cached by TanStack. getAll: publicProcedure .output(z.array(z.object({ id: z.string(), title: z.string(), }))) .query(({ ctx }) => ctx.db.task.findMany() ), // MUTATION — writes data. Invalidates cache. create: publicProcedure .input(z.object({ title: z.string() })) .output(z.object({ id: z.string(), title: z.string(), })) .mutation(({ ctx, input }) => ctx.db.task.create({ data: input }) ), // Rule of thumb: // Contract first → .input(...) + .output(...) // Then choose → .query() or .mutation()
Anatomy of a procedure
Every procedure is a contract-first chain: define input/output contracts, then implement resolver logic.
create: publicProcedure // ❶ Base — public or protected .input(z.object({ // ❷ Input — Zod validates at runtime title: z.string().min(1, // Bad data never reaches your DB "Title is required"), // Custom error messages priority: z.enum([ // Enum = exact string union "LOW", "MEDIUM", "HIGH" ]).default("MEDIUM"), // Default if not provided projectId: z.string(), })) .output(z.object({ // ❸ Output — response contract id: z.string(), title: z.string(), priority: z.enum(["LOW", "MEDIUM", "HIGH"]), projectId: z.string(), })) .mutation( // ❹ Type — .query() reads, .mutation() writes async ({ ctx, input }) => { // ❺ Resolver function // ctx = context (db, session, headers) // input = validated & typed by Zod ✓ return ctx.db.task.create({ // ❻ Prisma — typed DB query data: { title: input.title, priority: input.priority, projectId: input.projectId, }, }); // ❼ Return — checked against .output contract } ),
Base procedure
publicProcedure — anyone can call it. protectedProcedure — requires auth. The middleware runs here.
Input + output contract
.input(...) validates request shape and .output(...) validates response shape. Types are inferred on both sides.
Resolver + Return
Your business logic. ctx gives you the DB. Whatever you return becomes the client's response type.
Full CRUD router
import { z } from "zod"; import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; import { TaskForListSchema, TaskCreateInputSchema, TaskUpdateInputSchema, TaskDeleteInputSchema, } from "~/types/task"; const TaskMutationResultSchema = z.object({ id: z.string(), title: z.string(), priority: z.enum(["LOW", "MEDIUM", "HIGH"]), status: z.enum(["TODO", "IN_PROGRESS", "DONE"]), projectId: z.string(), }); export const taskRouter = createTRPCRouter({ getAll: publicProcedure .output(z.array(TaskForListSchema)) .query(async ({ ctx }) => { const tasks = await ctx.db.task.findMany({ include: { project: true, user: true }, orderBy: { createdAt: "desc" }, }); return tasks.map((t) => ({ ...t, project: { id: t.project.id, name: t.project.name }, user: t.user ? { id: t.user.id, name: t.user.name } : null, })); }), create: publicProcedure .input(TaskCreateInputSchema) .output(TaskMutationResultSchema) .mutation(({ ctx, input }) => ctx.db.task.create({ data: input }), ), update: publicProcedure .input(TaskUpdateInputSchema) .output(TaskMutationResultSchema) .mutation(({ ctx, input }) => { const { id, ...data } = input; return ctx.db.task.update({ where: { id }, data }); }), delete: publicProcedure .input(TaskDeleteInputSchema) .output(TaskMutationResultSchema) .mutation(({ ctx, input }) => ctx.db.task.delete({ where: { id: input.id } }) ), });
Pattern from demo-app
Router procedures do not define shapes inline. They import shared schemas from src/types/task.ts, so input validation, return contracts, and client types stay synced. This is contract-first in practice.
Single source of truth: type flow
In @demo-app, we used a contract-first pattern: define contracts once, then implement routers and UI against them.
Shared types + router contracts
// types/task.ts export const TaskCreateInputSchema = TaskInputSchema .pick({ title: true, priority: true, projectId: true }) .extend({ title: z.string().min(1) }); export const TaskForListSchema = TaskModelSchema .omit({ project: true, user: true }) .extend({ project: z.object({ id: z.string(), name: z.string() }), user: z.object({ id: z.string(), name: z.string() }).nullable(), }); // task router uses shared schemas create: publicProcedure.input(TaskCreateInputSchema)... getAll: publicProcedure.output(z.array(TaskForListSchema))...
Client receives inferred types automatically
// trpc/react.tsx export type RouterOutputs = inferRouterOutputs<AppRouter>; // TaskList.tsx type TaskItem = RouterOutputs["task"]["getAll"][number]; const { data: tasks } = api.task.getAll.useQuery(); tasks?.map((task) => ( <StatusBadge id={task.id} status={task.status} /> ));
Where contract-first shows up in code
src/types/task.ts defines reusable input/output contracts first. src/server/api/routers/task.ts consumes those contracts via .input(...) and .output(...). src/features/tasks/components/TaskList.tsx consumes inferred router output types.
What changes when schema changes?
Update Prisma once, regenerate, and TypeScript points to every broken assumption across router + components. That is the end-to-end single source of truth.
Protected procedures
Auth-gate any route with protectedProcedure. Don't deep-dive NextAuth yet — just know the pattern.
// Only authenticated users can call this createTask: protectedProcedure .input(z.object({ title: z.string().min(1), projectId: z.string(), })) .output(z.object({ id: z.string(), title: z.string(), projectId: z.string(), userId: z.string().nullable(), })) .mutation(({ ctx, input }) => { // ctx.session.user is guaranteed to exist return ctx.db.task.create({ data: { ...input, userId: ctx.session.user.id, }, }); }),
The pattern repeats
Same shape every time: Zod input → Prisma query → return. The client SDK is auto-generated and always in sync.
The extended workflow
After today, your backend is done for any feature. The pattern is always the same.
// Today's assignment
- Design and implement a backend API surface for your Airtable-clone MVP (choose your own router breakdown)
- Support the core product loop: read data, create records, edit records, and remove records
- Keep it contract-first: explicit .input() and .output() schemas on write paths
- Add at least one useful query capability: filtering or sorting that a real user would need
- Return typed errors for invalid input or missing resources
- Add one bulk operation (reorder, batch update, or bulk delete)
- Add access control for write paths (for example, protectedProcedure)