Session 2 · Week 1

Your backend is
just functions.

tRPC gives you typed functions your frontend calls directly. Like an internal SDK that writes itself.

Powered by
Prisma tRPC Next.js TypeScript Tailwind TanStack
10 min

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

without-zod.ts
// 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

with-zod.ts
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 · continued

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

primitives.ts
// 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

composed.ts
// 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.

10 min

The tRPC vocabulary

Six terms. Once you know them, every tRPC codebase reads the same way.

fn

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.

Q

Query

A procedure that reads data. Think GET. Uses .query(). Called with useQuery() on the client.

M

Mutation

A procedure that writes data. Think POST/PUT/DELETE. Uses .mutation(). Called with useMutation().

ctx

Context

Shared state every procedure can access — database connection, session, headers. Created once per request.

mw

Middleware

Runs before a procedure. Can check auth, log, or add data to context. Chains with .use().

REST mental bridge

GET /api/tasksapi.task.getAll.useQuery().  POST /api/tasksapi.task.create.useMutation().  Same thing — but the compiler checks every call.

tRPC · architecture

How the pieces fit together

Context — shared state

src/server/api/trpc.ts
// 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

middleware
// 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

src/server/api/root.ts
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-vs-mutation.ts
// 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()
10 min

Anatomy of a procedure

Every procedure is a contract-first chain: define input/output contracts, then implement resolver logic.

src/server/api/routers/task.ts — annotated
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.

35 min live coding

Full CRUD router

src/server/api/routers/task.ts
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.

demo-app pattern

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.

1 prisma/schema.prisma
2 generated/zod + prisma
3 src/types/task.ts
4 taskRouter
5 React hooks + UI

Shared types + router contracts

src/types/task.ts + src/server/api/routers/task.ts
// 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

src/trpc/react.tsx + TaskList.tsx
// 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.

Quick look

Protected procedures

Auth-gate any route with protectedProcedure. Don't deep-dive NextAuth yet — just know the pattern.

protectedExample.ts
// 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.

5 min · wrap up

The extended workflow

1 Requirement
2 Contract
3 Implement Router
4 Build UI
5 Regenerate + Verify
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
Stretch
  • Add one bulk operation (reorder, batch update, or bulk delete)
  • Add access control for write paths (for example, protectedProcedure)