Skip to content
Chapter 47Lesson 1

Project overview

You already have a working read surface for an invoicing app. The org-scoped data layer you built earlier — six tables, their relations, a deterministic seed, and the two queries every later unit bends back to — ships in this starter alongside the list and detail pages that render it. Open the project and /invoices shows a paginated list of seeded invoices, each linking to a detail page; what it does not yet have is a single button that changes anything. This project closes that gap. You take that data layer and ship a full CRUD surface on top of it: a “new invoice” form, an “edit invoice” form, and a delete-with-confirmation button. Every one of those mutations flows through a single Server Action that parses its FormData with a Zod schema derived from the Drizzle table, hands back the canonical Result, and revalidates the list — the exact mutation flow nearly every SaaS feature you will ever write is a variation of.

The CRUD surface you'll build across the next five lessons — start here at `/invoices`: the seeded list with its top-right *New invoice* link and the inline create form above the rows.

You are not meeting new primitives in this project — the Zod, Server Action, and React 19 form chapters covered every one of them. This is where those isolated exercises lock together into one mutation surface a team would actually run in production. Each line below leads with the skill, not the syntax.

  • Deriving a mutation schema from the Drizzle invoices table with createInsertSchema plus refinement, then treating that one schema as the contract both the action and the form’s input names obey — once the schema is the source of truth, drift between them becomes impossible.
  • Writing Server Actions in the five-seam shape — parse, authorize, mutate, revalidate, return — that hand back a Result instead of throwing, so the caller always gets one predictable shape to render.
  • Building native React 19 forms with useActionState, uncontrolled inputs, useFormStatus, and useOptimistic, wired so the whole surface still works with JavaScript disabled.
  • Reaching for a Drizzle transaction when a mutation is multi-step, and keeping external calls out of it.

The surface has many files, but a single mutation walks a straight line through them, and once you can trace that line every later lesson reads as one move along it rather than an isolated trick. Here is the shape, not the detail — each seam gets its full explanation in the lesson that first opens it.

  1. The browser submits a native <form action={serverAction}>. No onClick, no fetch, no /api/* route — the form posts straight to the action, which is why the whole surface survives with JavaScript switched off.
  2. The Server Action runs five seams in order: it parses the FormData against the Zod schema, reads the active org and user from the auth stub, writes through the pooled Drizzle client (the delete inside a transaction), calls revalidatePath('/invoices'), and returns a Result.
  3. Back on the client, useActionState renders that same Result the action returned — field errors inline under each input, a banner for everything else — while useOptimistic paints the pending create row at the top of the list until the revalidated rows arrive and replace it.

One seam deserves a name before you meet it. The action reads its tenant context from getActiveContext(), a stub that returns the seeded org and user. That is the exact slot a real authentication wrapper drops into in a later unit, so when you reach the mutation body you reach for the stub, not for cookies() — there is no session to invent here, only a context to read.

This project continues the toolchain and the data layer you have been carrying — pnpm, the strict tsconfig, Biome, the next-themes providers, the Docker Postgres service, Drizzle, and the @t3-oss/env-nextjs boundary — and the work the chapter is actually about lives in eight files. The bold files are your stubs; each carries a TODO(L<n>) comment naming the lesson that completes it, and together they are the whole chapter. Everything else is provided — read the one-line note on the files a lesson will open, and leave the rest until you reach them.

  • Directorysrc/
    • Directorydb/ provided: full schema, relations, client, cursor, columns
      • Directoryqueries/
        • invoices.ts provided: listCustomers(organizationId)
    • Directorylib/
      • Directoryinvoices/
        • schema.ts provided: statusSchema, listInvoicesInputSchema
        • mutation-schemas.ts the write-side Zod schemas — create, update, delete TODO L2 / L3 / L4
        • queries.ts provided: listInvoices, getInvoiceDetail
        • actions.ts the three Server Actions — file-level 'use server' TODO L2 / L3 / L4
      • result.ts provided: Result<T>, ok(), err(), isUniqueViolation
      • auth-stub.ts provided: getActiveContext() resolving the seeded org + user
    • Directoryapp/
      • layout.tsx provided: root layout — <Providers> + <Toaster />
      • page.tsx provided: redirects / to /invoices
      • Directoryinvoices/
        • page.tsx provided: RSC — list, “New invoice” link, ?deleted banner
        • loading.tsx provided: skeleton
        • Directory_components/
          • optimistic-invoices-list.tsx the client list — useOptimistic + the inline create form TODO L5
          • deleted-toast.tsx provided: client island, Sonner toast from ?deleted
        • Directorynew/
          • page.tsx provided: RSC shell, fetches customers
          • loading.tsx provided: skeleton
          • new-invoice-form.tsx the create form — dual-mode (inline + standalone) TODO L2 / L5
        • Directory[invoiceId]/
          • page.tsx provided: RSC, loads detail, renders edit + delete forms
          • loading.tsx provided: skeleton
          • edit-invoice-form.tsx the edit form, prefilled from the loaded invoice TODO L3
          • delete-invoice-form.tsx the delete dialog + no-JS fallback form TODO L4
      • Directory_components/
        • providers.tsx provided: next-themes ThemeProvider
        • submit-button.tsx useFormStatus + the shadcn <Button> TODO L2
        • field-error.tsx renders Result.error.fieldErrors[name] TODO L2
    • Directorycomponents/
      • Directoryui/ provided: shadcn primitives — button, badge, card, dialog, input, label, native-select, separator, skeleton, sonner
  • Directorytests/
    • Directorylessons/ one placeholder spec per implementation lesson — the real assertions arrive lesson by lesson

The underscore-prefixed _components/ folders are an App Router convention: a folder whose name starts with an underscore is opted out of routing, so it is not a URL segment, just a place to keep components the routes share.

Two provided files are worth reading before you write a line of your own, because the build leans on both and never redefines either:

  • lib/result.ts holds the Result<T> type — a discriminated union that is either { ok: true; data } or { ok: false; error }, where the error carries a code, a userMessage, and optional fieldErrors. It defines seven error codes; this project uses four of them (validation, conflict, not_found, internal). Alongside the type sit the ok and err constructors and isUniqueViolation, a helper that recognizes a Postgres unique-constraint violation (SQLSTATE 23505, read off error.cause because the driver wraps it) so you can map it to a conflict instead of letting it crash. You read this type; you never redeclare it inside an action. The contract lives in one place.
  • lib/auth-stub.ts exposes async getActiveContext(), which returns { organizationId, userId } for the seeded “Acme” org and its owner. It resolves them by natural key — the org slug and the user’s email — with a small lookup at call time, because the seed assigns fresh UUIDv7 primary keys on every run, so the ids cannot be hardcoded. It is deliberately not session-shaped and reads no cookie. Each action calls it once, at the top of the body, exactly where a real authentication wrapper lands in a later unit. Naming it now is what keeps you from reaching for cookies() or inventing a session shape mid-chapter — that would only be code the auth unit rewrites.

Five implementation lessons turn those eight stubs into the finished surface, each closing on a state you can confirm in the browser.

Lesson 2 — Create an invoice

Derive the create schema, write createInvoice in the five-seam shape, and wire NewInvoiceForm with the reusable <SubmitButton> and <FieldError> so a valid invoice persists and redirects.

Lesson 3 — Edit an invoice

Extend the schema with an id, write updateInvoice with a tenant-scoped where, and prefill EditInvoiceForm so edits save in place and a duplicate number surfaces a conflict banner.

Lesson 4 — Delete with confirmation

Add deleteInvoice and a shadcn <Dialog> delete form that submits through the action, with an inline no-JS fallback.

Lesson 5 — Optimistic create

Layer useOptimistic on the list and a client-generated UUIDv7 so the new row appears instantly, reconciles by key, and rolls back on failure.

Lesson 6 — Transactional delete

Wrap the delete in a Drizzle transaction for atomic multi-step deletion and add a URL-param success toast.

Run these in order. This lesson is done when the dev server boots and serves the seeded /invoices list against a running Postgres — the read path is already there; the forms behind “New invoice” are the stubs you are about to fill.

  1. Get the starter codebase from the project repository, under Chapter 047/start/:

    Terminal window
    pnpm dlx degit terencicp/react-saas-course-projects/Chapter-047/start invoices-crud
    cd invoices-crud

    degit copies that folder into a fresh invoices-crud directory with no git history, and pnpm dlx runs the tool without installing it first. Every chapter project in the repo has a start/ and a solution/ sibling, so you can diff your work against the reference whenever you want.

  2. Install the dependencies:

    Terminal window
    pnpm install

    The repo is pnpm-only — a preinstall hook blocks any other package manager — and the versions are pinned. The install completes with no errors.

  3. Copy the example env file:

    Terminal window
    cp .env.example .env

    The db:* scripts load .env through dotenv-cli, while next reads the environment directly. This project reuses the same three variables the data-layer project introduced; no new ones:

    • DATABASE_URL — the pooled connection string the app’s db client uses. Locally postgres://postgres:postgres@localhost:5432/app, from the Docker service below.
    • DATABASE_URL_UNPOOLED — the unpooled URL Drizzle Kit uses to migrate and seed. The same value locally; the split is a no-op for now, staged for the Neon swap in a later unit.
    • SEED — the integer that seeds the deterministic PRNG so the seed comes out identical on every run. Defaults to 1.

    The defaults in .env.example already match a local Docker Postgres, so for local work you can copy the file as-is.

  4. Bring up the database:

    Terminal window
    docker compose up -d

    This starts the postgres:18 service on port 5432 in the background. The first run pulls the image; after that it is instant.

  5. Apply the schema and fill it with seed data:

    Terminal window
    pnpm db:migrate && pnpm db:seed

    db:migrate runs the one init migration that creates the six tables; db:seed then loads the deterministic seed — two orgs, four users, forty customers, and a few hundred invoices with line items. Both come out identical every run, so the list you verify against matches the one in the screenshots.

  6. Start the dev server:

    Terminal window
    pnpm dev

    The root path redirects to /invoices, which renders the seeded list — that read path is inherited from the data-layer project and works out of the box. Click “New invoice” and you reach a form that is still a bare heading, because the form components are stubs you fill in starting next lesson. A seeded list and an empty form behind it is exactly the runnable starting point you want.

When pnpm dev serves the seeded /invoices list against a running Postgres, you have the project’s floor in place — a real read surface waiting for its mutations. From here the work is the schema, the three actions, and the forms that drive them, and the reasoning behind every decision arrives in the lesson that makes it.