Skip to content
Chapter 47Lesson 1

Project: CRUD via Server Actions

You already have a read surface for the invoicing app: the org-scoped data layer from earlier units, plus the list and detail pages that render it. Open /invoices and you get a paginated list of seeded invoices, but nothing on the page changes anything yet.

This project adds the write surface: a new invoice form, an edit invoice form, and a delete-with-confirmation button, the full CRUD the read pages were missing. Every mutation flows through one Server Action that parses its FormData with a Zod schema derived from the Drizzle table, returns the canonical Result, and revalidates the list. It is the flow nearly every web app feature is a variation of.

The finished `/invoices`: the seeded list with a top-right *New invoice* link and an inline create form above the rows.

This project introduces no new primitives: the Zod, Server Action, and React 19 form chapters covered them all. Here you combine those isolated exercises into one mutation surface, practicing four things.

  • Deriving a mutation schema from the Drizzle invoices table with createInsertSchema plus refinement, then making that one schema the contract both the action and the form’s input names obey.
  • Writing Server Actions in the five-seam shape (parse, authorize, mutate, revalidate, return) that return a Result instead of throwing.
  • Building native React 19 forms with useActionState, uncontrolled inputs, useFormStatus, and useOptimistic that still work with JavaScript disabled.
  • Reaching for a Drizzle transaction when a mutation is multi-step, and keeping external calls out of it.

The surface spans many files, but a single mutation runs a straight line through them:

  1. The browser submits a native <form action={serverAction}>: no onClick, no fetch, no /api/* route. The form posts straight to the action, so the surface works with JavaScript off.
  2. The Server Action runs its five seams in order: parse the FormData against the Zod schema, read the active org and user from the auth stub, write through the pooled Drizzle client (the delete inside a transaction), call revalidatePath('/invoices'), and return a Result.
  3. Back on the client, useActionState renders that Result, field errors inline under each input and a banner for everything else, while useOptimistic paints the pending create row at the top of the list until the revalidated rows replace it.

The action reads its tenant context from getActiveContext(), the stub returning the seeded org and user. It marks the slot where a real authentication wrapper drops in later, so the mutation body reaches for the stub rather than cookies().

You inherit the toolchain and data layer from earlier chapters: pnpm, the strict tsconfig, Biome, the next-themes providers, the Docker Postgres service, Drizzle, and the @t3-oss/env-nextjs boundary. The work lives in eight bold stub files, each carrying a TODO(L<n>) comment naming the lesson that completes it; everything else is provided.

  • 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

A folder whose name starts with an underscore opts out of App Router routing, so _components/ is not a URL segment, just a shared home for the components its sibling routes use.

Two provided files are worth reading first, since the build leans on both:

  • 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. Alongside it sit the ok and err constructors and isUniqueViolation, which spots a Postgres unique-constraint violation (SQLSTATE 23505) so you can map it to a conflict instead of crashing.
  • lib/auth-stub.ts exposes getActiveContext(), returning { organizationId, userId } for the seeded org and its owner. It resolves them by natural key (org slug plus user email) because the seed assigns fresh UUIDv7 keys on every run, so the ids cannot be hardcoded. Each action calls it once at the top.

Five lessons turn those eight stubs into a working CRUD surface, each ending on a state you can check 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 so multi-step deletion is atomic, and add a URL-param success toast.

Run these in order. You are done when the dev server boots and serves the seeded /invoices list against a running Postgres.

  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 it without installing. Every chapter project ships a start/ and a solution/, so you can diff your work against the reference.

  2. Install the dependencies:

    Terminal window
    pnpm install

    The repo is pnpm-only: a preinstall hook blocks other package managers, and versions are pinned.

  3. Copy the example env file:

    Terminal window
    cp .env.example .env

    This project reuses the same three variables as the data-layer project:

    • DATABASE_URL — pooled connection string for the app’s db client; locally postgres://postgres:postgres@localhost:5432/app.
    • DATABASE_URL_UNPOOLED — unpooled URL for Drizzle Kit’s migrate and seed; the same value locally.
    • SEED — integer seed for the PRNG, so seeding is identical every run. Defaults to 1.

    The .env.example defaults already match a local Docker Postgres, so 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 loads a deterministic set: two orgs, four users, forty customers, and a few hundred invoices with line items.

  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 its components are stubs you fill in starting next lesson.