Skip to content

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.

Mailgun5 min readships at docs/solutions/mailgun/template-variables-vs-rendered-html.md

Tags: mailgun · email · templates · handlebars · react-email · recipient-variables · batch

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

// 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:

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:

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.