Back to path
BeginnerInvoiceDesk · Project 2 of 12 ~5h· 5 milestones

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".

Drizzle ORMPostgreSQL constraintsZod validationNext.js server actionsuseActionStateDatabase migrationsSoft deletesSchema design

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:

db/schema.tsts
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. 1

    Design the schema on paper first

    5 guided steps

    A 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. 2

    Write the Drizzle schema and migrate

    5 guided steps

    Drizzle'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. 3

    Validate at the boundary with Zod

    5 guided steps

    Drizzle'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. 4

    Wire create and edit with server actions

    5 guided steps

    Server 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. 5

    Soft-delete and audit fields

    5 guided steps

    Freelancers 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

3 starter files, ready to clone
5 guided milestones
5 full reference solutions
8 code blocks explained line-by-line
5 "is it working?" checks
6 interview questions it prepares you for

You'll walk away with

db/schema.ts with clients, invoices, invoice_items, the invoice_status enum, and every foreign key's ON DELETE decided.
A generated migration in db/migrations applied to the local database.
lib/validation.ts with Zod schemas mirroring the Drizzle schema and the Postgres enum.
Working create and edit server actions for clients and invoices using useActionState.
Soft-delete wired end to end: a deletedAt column, a filtered activeClients() helper, and a delete action that updates instead of removing.
db/DESIGN.md documenting the schema decisions.

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