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.
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.
How types break: REST / OpenAPI
Your PM wants a status field on tasks. Here's the typical REST workflow — and where it falls apart.
// 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.
// 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.
How types break: GraphQL
GraphQL has a schema — so it should be safe, right? Same feature, same field. Watch what happens.
type Task { id: ID! title: String! status: String! # "!" means non-null } type Query { tasks: [Task!]! }
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.
// 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.
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.
Why startups can't afford this
Big tech model
Specialized teams can split DB, API, and frontend contracts because ownership and release cycles are separated.
Startup reality
One team owns the full slice end to end and ships fast. Extra contract layers become internal coordination overhead.
Triple maintenance
When schema, API contract, and UI model are authored separately, every change must be updated in three places.
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."
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.
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.
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.
// 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.
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:
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.prisma → generated/prisma/models/Task.ts → taskRouter.getAll return type → typed client hook data → TaskCard prop checks. One schema edit propagates through the whole app at compile time.
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.
GraphQL
Client decides what data it needs. Server doesn't know or care who's calling.
REST
Agreed endpoints with HTTP conventions. You get what the endpoint returns.
tRPC
Frontend calls backend functions directly. The compiler is your API contract.
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.
The typesafety chain
Change a column — type errors cascade from database to UI. That's the north star.
The "aha" moment
Rename a column in schema.prisma. TypeScript errors light up in your router and your components. No manual syncing.
Scaffold
bun create t3-app@latest
Project map
// 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.
Schema to typed queries
Define your models, run a migration, get fully typed queries. No manual types.
1 — Define models
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
# 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
// 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.
The workflow
This is step 1 of every feature you'll ever build in this stack.
// 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
- Add ordering metadata for records you expect to sort/reorder often
- Add audit metadata (createdById, updatedAt)