# Mailgun templates and recipient variables versus rendering HTML in your app

> Mailgun stores Handlebars templates in its dashboard and substitutes variables at send time. Rendering in your app instead keeps email in code review: here is when each one wins.

Mailgun offers a templating system: you write Handlebars in the dashboard, store
it under a name, and send with `template: "welcome"` plus a JSON blob of
variables. It looks like the obvious way to do email: the provider has a
feature for it, so use the feature.

Then someone changes a template at 6pm on a Friday, no diff exists, nothing was
reviewed, and the following Monday nobody can explain why the receipt says
`{{firstName}}` to four hundred customers.

Both approaches are legitimate. They fail in different ways, and the choice is
about where your email content lives.

## Option A: Mailgun-side templates

```ts
// The template body lives in Mailgun's dashboard, not in your repo.
await client().messages.create(domain, {
  from,
  to: user.email,
  subject: "Welcome",
  template: "welcome",
  "h:X-Mailgun-Variables": JSON.stringify({ firstName: user.firstName }),
});
```

What you get:

- Non-engineers can edit copy without a deploy.
- Versioning inside Mailgun, with the ability to pin a version per send.
- **Recipient variables**: the real feature, and the reason to reach for this.
  One API call can send a personalised message to a thousand recipients, each
  seeing only their own address in the `To:` header.

What you give up:

- **Code review.** The content of a customer-facing message changes with no
  diff, no reviewer and no trace in your repository's history.
- **Local development.** You cannot render the template without calling Mailgun,
  so "does this look right" becomes a round trip through a dashboard.
- **Type safety.** `{{firstName}}` in the template and `firstName` in your
  payload are related by a string. Rename the field in your database and the
  email starts rendering a blank where a name used to be: silently, because
  Handlebars substitutes a missing variable with nothing.
- **Portability.** Your templates are now in a vendor's database. Migrating
  providers means recreating every one by hand.
- **Environment parity.** A template edited in production does not exist in
  staging unless somebody remembers.

## Option B: render in your app, send finished HTML

This is what `sendEmail` does. The template is a React component in your repo,
rendered to HTML and plain text before the API call:

```ts
const html = options.react ? await render(options.react) : options.html;
const text = options.react
  ? await render(options.react, { plainText: true })
  : options.text;

await client().messages.create(sendingDomain(), {
  from: fromAddress(),
  to: address,
  subject: options.subject,
  html,
  text,
  "h:Reply-To": options.replyTo ?? defaultReplyTo(),
});
```

The consequences are the mirror image:

- Copy changes go through a pull request, like every other user-facing string.
- `bun run email:dev` renders every template locally, with no network calls
  and no credentials.
- Props are typed. Rename `firstName` and the build fails at the call site
  instead of the mailbox.
- Both parts are generated from one source, so the plain-text alternative cannot
  drift away from the HTML. A message with no text part scores worse with spam
  filters, and here you get one for free.
- Switching providers is one file. The Resend battery exports the same
  `sendEmail` signature for exactly this reason.

The cost is real: a copy change needs a deploy, and a marketer cannot edit the
words without you.

## The rule of thumb

**Transactional mail belongs in your repo.** A receipt, a magic link, an invite,
a security alert: these are part of your product's behaviour. They are as
customer-facing as your checkout page, they encode business rules ("expires in
15 minutes"), and they should be reviewable by the same people who review the
code that triggers them.

**Marketing and lifecycle campaigns can live in the dashboard**, where the
people who write them can work without you. Those messages change often, are
authored by non-engineers, and do not carry credentials.

If you are building both, that is a real split: transactional through
`sendEmail`, campaigns through whatever tool your marketing team already has.
Do not let campaign tooling creep into the transactional path.

## The case where Mailgun-side templates genuinely win

Recipient variables. If you need one API call to deliver a personalised message
to a large batch (and you care that each recipient sees only their own address) server-side substitution is the mechanism that does it:

```ts
await client().messages.create(domain, {
  from,
  to: ["a@example.com", "b@example.com"],
  subject: "Your %recipient.plan% plan renews soon",
  template: "renewal-reminder",
  "recipient-variables": JSON.stringify({
    "a@example.com": { plan: "Pro" },
    "b@example.com": { plan: "Team" },
  }),
});
```

Rendering that in your app means one API call per recipient, which is exactly
what `sendEmail` does when you pass it an array, deliberately, so recipients
never appear in one another's `To` header. For a thousand people that is a
thousand round trips and a rate limit to respect, which is the trade: `sendEmail`
is the transactional path, and a thousand-recipient send is a batch job that
should use `recipient-variables` directly.

Note the exposure this creates, though: get the substitution wrong and one
customer's variables render in another's message. Anything sensitive (balances, addresses, names of other people) is worth the extra API calls.

## If you use Mailgun templates anyway

Reduce the blast radius:

- **Commit the template source to your repo** even though Mailgun serves it, and
  treat the repo copy as canonical. A diff you can read is worth the
  duplication.
- **Pin a version** per send (`t:version`) so a dashboard edit cannot change
  production until you promote it.
- **Never put a credential in a variable.** A magic link built by string
  concatenation inside Handlebars is a token in a vendor's template engine.
- **Send a test to yourself after every edit.** There is no type checker here;
  a test send is the only feedback you get.

## The one-line summary

Templates in the dashboard buy you speed for people who cannot deploy, and cost
you review, types and local rendering. For transactional email (the mail that
carries your product's promises) that trade is not worth it, and the only
compelling exception is batch personalisation through recipient variables.

---

Agentic Boilerplate: A Next.js repo your agent already knows. Free during launch, then $99 once.

- Site map for agents: https://agenticboilerplate.com/llms.txt
- Public API: https://agenticboilerplate.com/openapi.json
- Contact: agenticstudio@gmail.com
