Session 1 · Week 1

Schema-first.
Type-safe forever.

Traditional stacks break types across three layers. The T3 stack flows them from one schema — database to API to UI — with zero manual definitions.

Powered by
Prisma tRPC Next.js TypeScript Tailwind TanStack
Workshop overview

Week 1: Build with the T3 stack

Three sessions, one goal — go from zero to a fully typed, deployed feature.

Day 1 Today

Schema-first data layer

Prisma models, migrations, generated types. Your single source of truth.

Day 2

tRPC — backend is just functions

Full CRUD router, input validation with Zod, protected procedures.

Day 3

Frontend — close the loop

TanStack Query, Tailwind, and shipping a complete feature end-to-end.

Format

Short concept intro, then live coding together. You'll build as we go — not just watch.

By the end

You'll own a working app where one schema change ripples types from database to UI automatically.

The problem — REST

How types break: REST / OpenAPI

Your PM wants a status field on tasks. Here's the typical REST workflow — and where it falls apart.

1 Add status column to Postgres
2 Update Express route to return it
3 Update OpenAPI spec (forgotten)
4 Frontend uses stale types
backend/routes/tasks.ts
// v1: status is required
app.get("/api/tasks", async (req, res) => {
  const tasks = await db.query(`
    SELECT id, title, status FROM tasks
  `);
  res.json(tasks);
});

// Two months later: PM says "status is
// optional for imported tasks." You ALTER
// to allow NULL. The endpoint still works.
// You meant to update openapi.yaml too
// but the PR got merged without it.
frontend/components/TaskCard.tsx
// Auto-generated from OpenAPI (stale)
interface Task {
  id: string;
  title: string;
  status: string; // ← says required
}

function TaskCard({ task }: { task: Task }) {
  return (
    <span>{task.status.toUpperCase()}</span>
  );
}
// 💥 "Cannot read properties of null"
// User sees a white screen. No build error.

Where it broke

Types lived in three places — the Postgres column (ALTER TABLE), the OpenAPI YAML (status: {type: string}), and the generated frontend interface. The column changed. The other two didn't. No compiler caught it.

The problem — GraphQL

How types break: GraphQL

GraphQL has a schema — so it should be safer, right? It is, partially. But the type safety has gaps. Same feature, same field — watch where it falls short.

1 ALTER column in Postgres
2 Update schema.graphql (separate file)
3 Update resolver (no compile check)
4 Re-run graphql-codegen (manual step)
schema.graphql
type Task {
  id:     ID!
  title:  String!
  status: String!  # "!" means non-null
}

type Query {
  tasks: [Task!]!
}
resolvers/task.ts
export const resolvers = {
  Query: {
    tasks: async () => {
      return db.query(`
        SELECT id, title, status
        FROM tasks
      `);
    },
  },
};
// Resolver returns raw DB rows.
// Nothing type-checks this function
// against the SDL schema at compile
// time — it's just untyped glue code.
generated/graphql.ts (stale)
// Auto-generated by graphql-codegen
// Last run: 2 months ago
export interface Task {
  id: string;
  title: string;
  status: string; // still says required
}
// Schema changed to String (nullable)
// but nobody re-ran codegen.
// Frontend types are frozen in time.
frontend/components/TaskCard.tsx
const { data } = useQuery(GET_TASKS);

{data.tasks.map(task => (
  <span>{task.status.toUpperCase()}</span>
))}
// 💥 GraphQL catches the null at runtime
// and errors — but only in production.
// No compile-time warning. No IDE squiggle.

Where it broke

GraphQL does enforce ! at runtime — a null status would error in the response. But that's a runtime error in production, not a compile-time catch. The resolver is untyped glue: nothing checks it against the SDL schema when you build. And graphql-codegen only works if someone remembers to run it. Types still live in three places: SDL schema, resolver logic, generated client types.

The pain

Why startups can't afford this

BT

Big tech model

Specialized teams can split DB, API, and frontend contracts because ownership and release cycles are separated.

SU

Startup reality

One team owns the full slice end to end and ships fast. Extra contract layers become internal coordination overhead.

×3

Triple maintenance

When schema, API contract, and UI model are authored separately, every change must be updated in three places.

↓0

Optimal for startups

Use one source of truth and generate outward. Less glue code, fewer sync meetings, fewer drift bugs.

"Big tech can afford more boundaries. Startups win by removing unnecessary ones."
The solution

Enter the T3 stack

In 2022, Theo Browne asked the same question and open-sourced a starter that wires these problems away. ~40k GitHub stars later, it's the default for TypeScript-first startups.

Next.js

Framework — routing, SSR, deploy

TypeScript

The language that makes it all work

Prisma

DB schema → generated types

tRPC

API layer with zero type drift

TanStack Query

Client data fetching + cache

Tailwind

Utility-first styling

Key insight

In our demo-app, prisma/schema.prisma is the single source of truth. Types flow from that file into Prisma Client, through tRPC procedures, and into React props automatically.

The difference

Same feature, T3 style

In demo-app: same task, same status field. One schema change, then types flow end-to-end and TypeScript points to every affected UI.

1 Edit demo-app/prisma/schema.prisma
2 Generated types land in demo-app/generated/prisma
3 tRPC return types update automatically
4 React components get compile-time feedback
demo-app/prisma/schema.prisma
model Task {
  id     String  @id @default(cuid())
  title  String
  status String? // ← make it optional here
}
// That's it. One line changed.
// Run: npx prisma migrate dev
// Prisma generates new types automatically.
demo-app/generated/prisma/models/Task.ts
// generated by prisma-client from schema.prisma
export type Task = {
  id: string;
  title: string;
  status: string | null;
  projectId: string;
  createdAt: Date;
};
// this is what tRPC/router code consumes
// and what eventually reaches the client types.
demo-app/src/app/components/TaskCard.tsx ← error
function TaskCard({ task }: { task: Task }) {
  return (
    <span>{task.status.toUpperCase()}</span>
  );        ~~~~~~~~
}
// TS2532: Object is possibly 'null'.
// Compiler catches it BEFORE you run
// the app. You fix it right here:
demo-app/src/app/components/TaskCard.tsx ← fixed
function TaskCard({ task }: { task: Task }) {
  return (
    <span>
      {task.status?.toUpperCase() ?? "No status"}
    </span>
  );
}
// ✅ Compiles. Handles null. Ships safe.

Zero drift

Type flow in demo-app: prisma/schema.prismagenerated/prisma/models/Task.tstaskRouter.getAll return type → typed client hook data → TaskCard prop checks. One schema edit propagates through the whole app at compile time.

The boldest choice

Why tRPC? The coupling spectrum

The most opinionated piece of T3 is tRPC — it fully couples your frontend to your backend. That sounds scary. Here's why it's the right call.

Decoupled Tightly coupled

GraphQL

Most decoupled

Client decides what data it needs. Server doesn't know or care who's calling.

REST

Contract-based

Agreed endpoints with HTTP conventions. You get what the endpoint returns.

tRPC

Fully coupled

Frontend calls backend functions directly. The compiler is your API contract.

Comparison

Tradeoffs at a glance

GraphQL

  • + Fetch exactly what you need
  • + Great for multi-client apps
  • Resolvers, N+1, codegen overhead
  • Complex normalized caching

REST

  • + Any client, any language
  • + Simple HTTP caching
  • Types drift between client & server
  • Over-fetching or many round trips

tRPC

  • + Zero type drift — compiler enforces it
  • + No codegen, no schema files
  • Both sides must be TypeScript
  • Not for public / 3rd-party APIs

When it's perfect

One team, one repo, one language. You own both sides of the wire. Ship fast with full type coverage.

When to skip it

Public API? Mobile clients in Swift/Kotlin? Multiple teams deploying independently? Use REST or GraphQL instead.

10 min

The typesafety chain

Change a column — type errors cascade from database to UI. That's the north star.

PrismaDatabase schema
tRPCAPI layer
FrontendReact + TanStack

The "aha" moment

Rename a column in schema.prisma. TypeScript errors light up in your router and your components. No manual syncing.

10 min

Scaffold

~/projects
bun create t3-app@latest

Project map

structure
// Where your data lives
prisma/schema.prisma

// Your backend
src/server/api/
  ├── routers/
  └── trpc.ts

// Your frontend
src/app/
  ├── layout.tsx
  └── page.tsx

What matters now

  • prisma/schema.prisma — single source of truth
  • src/server/api/ — tRPC routers live here
  • src/app/ — Next.js file-based routing

Don't overthink it

You don't need to understand every file today. Just know where your data, backend, and frontend live.

30 min live coding

Schema to typed queries

Define your models, run a migration, get fully typed queries. No manual types.

1 — Define models

schema.prisma
model Project {
  id        String   @id @default(cuid())
  name      String
  tasks     Task[]
  createdAt DateTime @default(now())
}

model Task {
  id        String   @id @default(cuid())
  title     String
  priority  Priority @default(MEDIUM)
  project   Project  @relation(...)
  projectId String
  assignee  User?    @relation(...)
  userId    String?
}

enum Priority {
  LOW
  MEDIUM
  HIGH
}

2 — Migrate & generate

terminal
# Create & apply the migration
npx prisma migrate dev --name init

# Generate the typed client
npx prisma generate

# Open the visual editor
npx prisma studio

3 — Typed queries

queries.ts
// Full autocompletion, zero manual types
const tasks = await prisma.task.findMany({
  where: { priority: "HIGH" },
  include: { project: true },
});
// → (Task & { project: Project })[]

Zero manual types

Every type came from the schema. Prisma generated them. You never wrote interface Task { ... }. That's the point.

5 min

The workflow

This is step 1 of every feature you'll ever build in this stack.

1 Requirement
2 Schema Change
3 migrate dev
4 generate
5 Typed Queries

// Today's assignment

  • Design the core data model for your Airtable-clone in schema.prisma.
  • Define at least 3 related models with practical constraints.
  • Run prisma migrate dev and confirm the migration runs from a clean DB
  • Seed realistic demo data that matches your chosen product shape
  • Use Prisma Studio to validate relational integrity and spot-check sample queries
Stretch
  • Add ordering metadata for records you expect to sort/reorder often
  • Add audit metadata (createdById, updatedAt)