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 safe, right? Same feature, same field. Watch what happens.

1 Add status to schema.graphql
2 Write resolver to fetch from DB
3 Run graphql-codegen
4 Frontend uses generated types
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
      `);
    },
  },
};
// DB has NULLs for imported tasks
// Resolver doesn't validate — just
// passes rows through. Schema says !
// but GraphQL doesn't check at runtime.
generated/graphql.ts (stale)
// Auto-generated by graphql-codegen
// Last run: 2 months ago
export interface Task {
  id: string;
  title: string;
  status: string; // codegen trusts the "!"
}
// Nobody re-ran codegen after the
// schema changed. Types are frozen.
frontend/components/TaskCard.tsx
const { data } = useQuery(GET_TASKS);

{data.tasks.map(task => (
  <span>{task.status.toUpperCase()}</span>
))}
// 💥 Same crash. "!" was a lie.
// Resolver returned null, codegen
// was stale, nobody caught it.

Where it broke

GraphQL adds a schema layer — but it's not validated at runtime. The ! in String! is a promise, not an enforcement. And graphql-codegen only works if someone remembers to run it. Types still live in multiple 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)