Give it a real schema and typed CRUD
Continues from the last build: It runs locally and you can trace a request from the browser to Postgres and back.
Rung 1 left you with a schema you'd be embarrassed to show a client: invoices.amount is a text column holding strings like "129.99", there's no invoice_items table so a bill is just one lump number, and nothing stops you from inserting a client with no name or an invoice with a status of "lol".
What you'll build
By the end, it has a real schema: three tables with typed columns, an enum Postgres itself enforces, and foreign keys that fail loudly instead of orphaning data. Every create and edit goes through a Zod safeParse before it reaches Drizzle, server actions return typed field errors instead of crashing, and deleting a client sets deletedAt instead of running DELETE. You can trace any piece of bad input from the form to the exact line that rejected it.
See how we teach, before you sign up
You don't just get code dumped on you. Every starter file and every solution is explained line-by-line, in plain English. Here's one real file from this project:
import { pgTable, uuid, text } from 'drizzle-orm/pg-core';
// Toy schema from rung 1: no line items, no constraints, money stored as text.
export const clients = pgTable('clients', {
id: uuid('id').primaryKey().defaultRandom(),
name: text('name'),
});
export const invoices = pgTable('invoices', {
id: uuid('id').primaryKey().defaultRandom(),
clientName: text('client_name'),
amount: text('amount'), // "129.99", a string, this is the bug you're fixing
});
Reading this file
amount: text('amount')This is the exact anti-pattern rung 2 removes, money stored as a string.clientName: text('client_name')No foreign key, just a copied string, no relational integrity at all.name: text('name')Nullable with no constraints, an empty client can be inserted today.
Starter file for this rung.
That's 1 of 8 explained code blocks in this single project.
The build, milestone by milestone
- 1
Design the schema on paper first
5 guided stepsA schema written straight into code without deciding constraints first produces silent bugs: floats for money, invoices that vanish when you delete a client, no way to tell a mistake row from a real deletion. Fifteen minutes with a pen catches these before they're baked into a migration history you have to unwind.
- 2
Write the Drizzle schema and migrate
5 guided stepsDrizzle's schema is TypeScript, so every column mistake is caught by the compiler before it becomes a runtime bug or a bad migration you have to write a follow-up migration to fix.
- 3
Validate at the boundary with Zod
5 guided stepsDrizzle's types guarantee shape once data reaches the database, but they don't stop a browser from submitting a negative quantity or an empty client name. Zod is the boundary that catches bad input before it reaches a query, with error messages you can show next to the actual form field.
- 4
Wire create and edit with server actions
5 guided stepsServer actions are how a Next.js App Router mutation actually happens, no separate API route to hand-roll. Getting the use server directive, the error shape, and revalidatePath right here is what makes every future CRUD screen in the app follow the same pattern.
- 5
Soft-delete and audit fields
5 guided stepsFreelancers dispute invoices and ask where a client went months later. A hard DELETE means the answer is gone forever. A deletedAt column means you can always answer, and it gives you createdAt and updatedAt for free, which every report in later rungs will need.
What's inside when you start
You'll walk away with
This is portfolio-grade. Build it free.
Sign up to unlock every milestone step-by-step, the code skeletons, full reference solutions, and checkable tasks, with your progress saved as you build.
Start building