At some point in any app’s lifecycle, something happens in the system that users need to know about. A role gets assigned. A form is submitted. A record is updated or cancelled. The instinct to send an email is usually right — but wiring it up cleanly takes more thought than it first appears.

This post covers the patterns that have worked well: how to structure the code, a few things that are easy to get wrong, and the pieces that are often skipped entirely.


Pick a Provider Early

The provider decision matters less than you think, but make it early. What you want is an API-first service with solid delivery infrastructure, a simple SDK, and straightforward pricing. The ecosystem has a few good options — what they all have in common is that they expose a clean send API and handle the deliverability complexity for you.

What you do not want is to call the provider directly from all over your codebase. Isolate it behind a thin abstraction from day one.


Isolate Your Email Module

Create a dedicated directory for all email-related code — lib/email/ or similar. The structure that works well:

  • A client module that initializes the provider SDK and exports a FROM_EMAIL constant
  • A templates directory with one file per email type
  • A send helper per trigger — one function per email, e.g. sendRoleAssignmentEmail, sendRegistrationConfirmEmail

Each send helper takes typed inputs, builds the email, and calls the provider. Nothing outside this directory knows how email is sent.

The payoff: you can change providers, update templates, or add logging in one place without touching every API route that happens to trigger an email.


Write Templates as Components

Component-based email libraries let you write templates in JSX with inline styles. This is substantially more maintainable than raw HTML strings or text templates with placeholder substitution.

A few things that pay off early:

Shared styles in one file. Inline styles are necessary for email clients, but defining them once and importing into templates gives you a consistent look and eliminates repetition.

One template for related states. A registration confirmation email might have three states: new, updated, cancelled. Rather than three separate templates, use props to control what’s rendered. The shared structure is obvious, and the differences are explicit.

Keep templates dumb. Templates should receive formatted strings — dates, names, URLs — not raw database objects. The formatting logic lives in the send helper, not the template. This keeps templates easy to reason about and preview in isolation.


The Non-Production Guard

This is the most important piece that’s almost always missing from early implementations: an environment check that prevents emails from reaching real users outside production.

The pattern: check your base URL or an environment variable against the production domain. If it doesn’t match, filter the recipient list to a small set of internal addresses — typically just admin or developer accounts. Every send function passes recipients through this guard before touching the provider API.

Without this, staging deployments and local development can accidentally spray emails at real users. It’s a bad experience and often embarrassing. The guard is a one-time, five-minute addition that prevents a whole category of mistake.


Fire and Forget — Carefully

In most cases, a transactional email is non-critical to the underlying operation. A registration was submitted whether or not the confirmation email went out. So email sends are typically fire-and-forget: call the send helper without awaiting it, and attach a .catch(console.error) to log failures without crashing the request.

sendRegistrationConfirmEmail(data).catch(console.error);

The exception is when the email is the product — password resets, verification codes. Those need to await and surface errors explicitly. Fire-and-forget breaks badly if a reset code send fails silently.


Batch Sending for Multi-Recipient Notifications

When an event needs to notify multiple people — say, a new form submission going to every admin and program manager — don’t loop over recipients and fire individual sends. Most providers rate-limit API calls, and a naive loop will hit that limit as the team grows.

Use the provider’s batch send API instead. One API call, array of messages. The recipient list gets built once, passed through the non-production guard, then sent as a single batch.


User Preferences

Not every user wants every email. Give users control — at minimum, a simple opt-out toggle accessible from their profile.

A few notes on implementation:

A missing preferences record should be treated as opted in. Don’t require users to take action before receiving emails; require action to stop receiving them.

Check preferences before sending informational notifications (“a new thing happened”), but not before sending strictly transactional emails (“here’s confirmation of what you just did”, “your role was changed”). The latter are receipts, not notifications.

Store preferences in your own database, not in the provider. You own that data, and it should be queryable alongside everything else.


What Doesn’t Need to Be Fancy

Email is a surface where perfectionism rarely pays. The things worth getting right:

  • Subjects specific enough to be scanned at a glance
  • One clear call-to-action per email
  • A working link back to the relevant page in the app
  • A way for users to adjust their preferences

Everything else — pixel-perfect layouts, extensive HTML/CSS tricks, marketing copy — can come later, if at all. Get the emails sending reliably first.