Skip to content
Chapter 50Lesson 1

Project: the welcome email send path

Almost every SaaS app sends the same first message: a welcome email the moment someone signs up. It is the most boring transactional surface in the product and the most load-bearing — if it lands in spam, the user never confirms their address, and every flow downstream stalls. This project ships that send end-to-end and, in doing so, builds the structural floor every later send in the course stands on. The verification email Better Auth fires on sign-up, the invitation email when you add organizations, the billing receipt, the notification dispatcher’s email channel — all of them reuse the exact same wrapper, the exact same suppression discipline, and the exact same Result shape you install here. The chapter is one Server Action calling one template through one wrapper. Once that floor is poured, adding a new send is “write the template, write the action, call sendEmail” — never “remember to check the suppression list, remember to set the idempotency key, remember to default the from address.” Those reflexes live in the seam, not in your memory.

The finished inspector at /inspector/send-welcome — the send form on the left (Recipient email, First name, the Send welcome button) beside the live preview iframe rendering the welcome template: the brand header logo, the 'Welcome, Ada' heading, the body paragraph, the branded 'Verify your email' button, the alternate link, and the footer legal line. The send itself is verified against your own inbox: a successful submit returns the Resend send ID, and Gmail's Show original panel reports SPF, DKIM, and DMARC all passing on your verified subdomain.

You are not meeting new primitives in this project — the email chapters and the Server Actions chapters covered every one of them. This is where those isolated pieces lock together into one send path a team would actually run in production. Each line below leads with the skill, not the syntax.

  • Installing a side-effect boundary (src/lib/email.ts) as the single chokepoint every email the app sends has to pass through.
  • Reading a suppression list at that boundary and short-circuiting before the external call ever happens.
  • Writing a props-only React Email template and eyeballing it across viewports and color schemes.
  • Composing a Server Action in the five-seam shape that hands back a Result instead of throwing.
  • Proving deliverability against a real inbox by reading the authentication results out of the message headers.

The chapter touches a handful of files, but a single send 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.

Inspector form client component
sendWelcomeEmail Server Action
chokepoint sendEmail src/lib/email.ts
Resend the vendor
Your inbox verified domain
One send, five hops — and a single chokepoint. Every future email re-enters this line at the wrapper.

Walk it left to right:

  1. The inspector form (a client component) posts recipientEmail and firstName straight to the Server Action — a native <form action={…}>, no fetch, no /api/* route.
  2. The sendWelcomeEmail Server Action runs five seams in order: it parses the FormData with Zod, reads the active user from the auth stub, computes the idempotency key, builds a placeholder verifyUrl, and calls the wrapper.
  3. src/lib/email.ts is the chokepoint: it normalizes the recipient, reads the suppression list, and only then calls the Resend SDK — defaulting the from and reply_to from validated env and returning a Result.
  4. Resend hands the message off, and it lands in your real inbox on your verified domain.

Two facts sit off to the side of that line. The <WelcomeEmail /> template is rendered twice: the action renders it on the way to a real send, and the inspector page renders it again into the preview iframe so you can see the email without leaving the app — the same props-only component, proving it renders identically in both places. And before any of this runs, src/env.ts validates the five new environment entries at build and boot, so a missing key fails the server up front rather than at the first send.

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 five stubs. 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/
    • env.ts the @t3-oss/env-nextjs boundary — add the five email + app vars TODO L3
    • Directorydb/
      • index.ts provided: the db client (postgres-js), snake-case casing
      • schema.ts provided: the email_suppressions table + suppression_reason enum
      • columns.ts provided: the shared timestamps column group
    • Directorylib/
      • email.ts the Resend client singleton + the sendEmail wrapper with the suppression read TODO L3
      • suppressions.ts isSuppressed(email, { kind }), normalize-on-read TODO L3
      • result.ts provided: Result<T>, ok(), err(), isUniqueViolation
      • auth-stub.ts provided: getActiveContext() resolving the seeded org + user
      • utils.ts provided: the cn() class-merge helper
    • Directoryemails/
      • welcome.tsx the WelcomeEmail template + its PreviewProps TODO L4
      • email-tailwind-config.ts provided: the shared email Tailwind config
      • Directorycomponents/
        • email-layout.tsx provided: the brand header + footer chrome (literal constants, no env reads)
    • Directoryapp/
      • page.tsx provided: redirects / to /inspector/send-welcome
      • layout.tsx provided: root layout — <Providers> + <Toaster />
      • Directory_components/
        • providers.tsx provided: the next-themes ThemeProvider
        • submit-button.tsx provided: useFormStatus + the shadcn <Button>
        • field-error.tsx provided: renders Result.error.fieldErrors[name]
      • Directoryactions/
        • send-welcome.tsx the sendWelcomeEmail Server Action — .tsx, it constructs JSX TODO L4
      • Directoryinspector/
        • Directorysend-welcome/
          • page.tsx provided: the server-rendered inspector — form + live preview iframe
          • send-welcome-form.tsx provided: the client form reading useActionState, three result cards
    • Directorycomponents/
      • Directoryui/ provided: shadcn primitives — button, card, input, label, separator, skeleton, sonner
  • Directoryscripts/
    • seed.ts provided: inserts the org, the user, and one pre-suppressed row
  • drizzle/0000_init_schema.sql provided: organizations, users, email_suppressions + the enum
  • docker-compose.yml provided: the postgres:18 service on :5432
  • .env.example provided: every variable to copy into .env
  • README.md provided: the verified-domain ceremony recap + the DNS checklist

The underscore-prefixed _components/ folder is 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.

A few provided pieces are worth a line now, because lessons lean on them and never redefine them:

  • The inspector page (src/app/inspector/send-welcome/page.tsx) renders a live preview iframe of the template beside the client form. The form (send-welcome-form.tsx) posts recipientEmail and firstName to the action you write and renders one of three result cards against the Result it gets back — a success card with the Resend send ID, a suppression card when the code is forbidden, and a generic error card for everything else. Read both in full when you wire the action.
  • src/emails/components/email-layout.tsx is the brand surface — a header band with the logo and a footer with the legal line, all on literal constants with no env reads — and src/emails/email-tailwind-config.ts is the shared email Tailwind config the welcome template’s <Tailwind> consumes. Both get unpacked when you write the template.
  • src/lib/result.ts, src/lib/auth-stub.ts, src/db/*, and scripts/seed.ts are carry-ins from earlier projects. The seed inserts the org, the user, and one pre-suppressed row, and getActiveContext() resolves the seeded org and user by natural key — the org slug acme and the user email ada@acme.test — which is why the seed has to run before the action can read an identity.

Three lessons turn those five stubs into a real, verified send, each closing on a state you can confirm — in the Resend dashboard, in a query, or in your own inbox.

Lesson 2 — The verified-domain ceremony

Stand up Resend on your own domain and get the transactional subdomain to Verified, with SPF, DKIM, and DMARC all passing — the unblocking gate for everything after it.

Lesson 3 — The suppression-gated send wrapper

Add the email env entries, write isSuppressed, and build src/lib/email.ts as the single send seam that reads the suppression list and requires an idempotency key.

Lesson 4 — The welcome email send path

Write the <WelcomeEmail /> template and the sendWelcomeEmail Server Action so the inspector button delivers a real, rendered email end-to-end.

This chapter needs one thing the previous projects did not.

Run these in order. This lesson is done when both dev servers boot and /inspector/send-welcome renders the form beside the preview iframe — the email entries are not validated yet, so you can complete the whole setup before you have a Resend key.

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

    Terminal window
    pnpm dlx degit terencicp/react-saas-course-projects/Chapter-050/start welcome-email
    cd welcome-email

    degit copies that folder into a fresh welcome-email 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. 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. The database variables already match the local Docker Postgres below, so the database works as-is. The five email and app variables ship with placeholder values — you fill the real RESEND_API_KEY in the next lesson and the rest after that. They are listed in full below.

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

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

  5. Apply the init migration:

    Terminal window
    pnpm db:migrate

    This runs the one migration that creates the organizations, users, and email_suppressions tables, plus the suppression_reason enum.

  6. Before you seed, open scripts/seed.ts and replace the placeholder suppressed address (suppressed@send.acme.example) with suppressed@send.<your-domain> — the README calls this out under “Seed placeholder”. Then seed:

    Terminal window
    pnpm db:seed

    This inserts the org, the user, and the one pre-suppressed row. That address never needs to exist as a real mailbox: the suppression check short-circuits at the application layer before Resend would ever attempt delivery, so the destination is irrelevant on that path.

  7. Start both servers, side by side, and leave them running for the rest of the chapter:

    Terminal window
    pnpm dev
    Terminal window
    pnpm email

    pnpm dev is the Next app at http://localhost:3000. pnpm email is the React Email preview server at http://localhost:3001 — the script bakes in --dir ./src/emails --port 3001 so it never clashes with the dev server. You use the dev server’s inspector to fire real sends and the preview server to iterate on the template itself.

The .env file carries eight variables. Three are carried in from the data-layer project; five are new to this chapter.

| Variable | Purpose | Where the value comes from | | --- | --- | --- | | DATABASE_URL | Pooled connection string the app’s db client uses. | The Docker Postgres; the .env.example default works locally. | | DATABASE_URL_UNPOOLED | Unpooled URL Drizzle Kit uses to migrate and seed. | Same value locally; the split is staged for the Neon swap later in the course. | | SEED | Seeds the deterministic PRNG. | The .env.example default of 1. | | RESEND_API_KEY | Authenticates the Resend SDK. Server-only. | The Resend dashboard, in the next lesson. | | EMAIL_FROM | The verified sender — a full Display Name <local-part@send.domain.tld> header. | Your verified domain, set in Lesson 3. | | EMAIL_REPLY_TO | The monitored mailbox replies land in, instead of the noreply@ sender. | A real address you read, set in Lesson 3. | | NEXT_PUBLIC_APP_NAME | Read by the action for the email subject. | Your app’s name, set in Lesson 3. | | NEXT_PUBLIC_APP_URL | Read by the action to build the placeholder verifyUrl. | http://localhost:3000 locally, set in Lesson 3. |

This lesson does not require the email entries to be valid. The src/env.ts schema does not pick them up until Lesson 3, so the placeholder values are fine for now.

When pnpm dev serves /inspector/send-welcome with the form rendered beside the preview iframe, you have the project’s floor in place. The iframe shows a skeleton of the template until you write it in Lesson 4, and clicking “Send welcome” returns Not implemented because sendWelcomeEmail is still a stub — that error is the intended runnable starting point, not a problem to fix. The first real move is in the next lesson, where you get your own domain to Verified so every send after it can prove who it came from.