Skip to content

Templates: four engines, and which one you actually want

The structured AST, handlebars, MJML and raw HTML — what each is for, why handlebars escapes triple braces identically to double, and where MJML cannot run.

SendingIntermediate13 min readUpdated
YOU'LL NEED

01Four engines, one entry point

Everything that renders a message — the send path, broadcast fan-out, the dashboard preview — calls one function, renderTemplate, and nothing else. That is not tidiness for its own sake: it is the only way a template can be guaranteed to render identically in the preview you approved and in the broadcast that goes out an hour later. Two code paths would eventually be two behaviours.

TL;DR

The choice comes down to two questions: who edits this, and what runtime does it have to render on. Everything else is taste.

One entry point, renderTemplate, covers all four.
jsx-ast
handlebars
mjml
html
What you store
A validated JSON document
A string with merge tags
MJML source, compiled to HTML
Raw HTML, verbatim
Interpolates
Yes
Yes
Yes
No — tags left in are a warning
Renders on a Worker
Yes
Yes
Throws MjmlUnavailableError
Yes
Editable as a form
Yes
As text
As text
As text
Choose it when
Somebody who is not you edits it
It is a string with names in it
You already have MJML and a build step
The body came from elsewhere and must not be touched

Who edits this? If the answer includes anybody who does not want to see angle brackets, you want a structured document rather than a string, because only a structured document can be presented as a form. What runtime does it have to render on? If the answer is a Cloudflare Worker — which it is, for every send on a default deployment — then MJML is out, and that is a hard constraint rather than a preference.

02The structured AST, and its fifteen components

The jsx-ast engine is the one that sounds most exotic and is in practice the most boring, which is the point. You author a React-Email-shaped .tsx file; mailysend templates push runs it through a real parser on your machine, where a parser and a filesystem are entirely reasonable things to have; and what gets stored is a data-only JSON tree.

TL;DR

The server never sees JSX and never evaluates anything — which is what makes a visual editor and a safe render the same feature rather than two competing ones.

// welcome.tsx — what you write
<Container>
  <Preview>Your account is ready</Preview>
  <Heading level={1}>Hi {contact.first_name | default:"there"}</Heading>
  <Text>Two things to do first.</Text>
  <Button href={"{{ activation_url }}"}>Activate</Button>
</Container>

That split is the whole design. A template body is customer-controlled data that gets stored and later rendered inside a shared isolate on behalf of somebody else’s send, so anything in that path capable of evaluating an expression from the body is a sandbox escape waiting to be found.

The expression language hasBecause
No function calls, no arithmeticNeither can be bounded, and neither is needed to name a field.
No operators outside a fixed comparison setAn enumerable set is the only kind that can be reviewed.
No __proto__, constructor or prototypeThe path schema refuses those property names outright.
A cap of twelve path segmentsA path that deep is a bug, not a lookup.
Escaping at every interpolation siteThere is no opt-out, so there is no gap.

Fifteen components make up the document. They are deliberately email components rather than web ones — there is no div, because a div is not how you lay out a message that has to survive Outlook.

ComponentWhat it is
HtmlThe document root
HeadWhere a style block lives
BodyThe outer background
ContainerThe centred fixed-width column
SectionA horizontal band
RowA table row
ColumnA cell inside a row
TextA paragraph
HeadingH1 through H6
ButtonA bulletproof padded anchor
LinkAn inline anchor
ImgAn image with dimensions
HrA rule
PreviewThe hidden inbox preview line
CodeBlockMonospaced, for tokens and ids

The filter chain — {value | formatDate:“short”} — resolves against the exact same helper table the handlebars engine uses, on purpose, so that {{formatDate x “short”}} and its AST equivalent cannot drift into producing different output.

03Handlebars, interpreted rather than compiled

This is a handlebars interpreter, not a handlebars compiler. Upstream handlebars compiles a template into a JavaScript function; this one parses the template into a tree and walks it.

TL;DR

The runtime has no new Function and no eval, by design, and that single fact explains every difference you will notice.

GroupThe whole listNote
Block helpersif · unless · each · withFour. There is no way to add a fifth from a template.
Formattingupper · lower · capitalize · truncate · default · formatDate · formatNumber · pluralize · linkformatDate defaults to UTC and takes an explicit tz when you want otherwise. default treats the empty string as missing, because “Hi ,” is the failure everybody has received.
Comparison and logiceq · ne · gt · gte · lt · lte · and · or · notBeing able to enumerate what a template can do is the only form of sandboxing that survives contact with untrusted authors.
Hi {{capitalize first_name}},

{{#if plan}}You are on the {{plan}} plan.{{/if}}

You have {{formatNumber credits}} {{pluralize credits "credit" "credits"}} left,
expiring {{formatDate expires_at "medium"}}.

{{#each items}}  · {{this.name}}
{{/each}}

The UTC default is not a shrug. A Worker’s clock is always UTC while a self-hosted Node box is whatever the operator set, and “the same broadcast rendered a different date depending on which runtime picked up the job” is a bug that only appears near midnight, in production.

04MJML, and the runtime it needs

MJML is supported and it is Node-only. The compiler needs Node APIs that Cloudflare Workers does not provide, so on a Worker the engine throws MjmlUnavailableError immediately rather than attempting a partial render.

TL;DR

The design decision is in the word “immediately”: an error that names the fix is worth more than a capability check, and both supported answers are build-time.

What the engine checksWhat it concludes
navigator.userAgent === "Cloudflare-Workers"The documented Workers signal. MJML cannot run here; throw before doing anything else.
process.versions.nodeA version string is what distinguishes real Node from the Deno and Bun shims that also define process.
A dynamic import, indirected via a variableSo that bundlers which statically rewrite import("mjml") leave the Worker build alone.
The optional dependency is not installedThe same error class with the reason in it — not a stack trace about a missing module.
// on a Worker:
MjmlUnavailableError: MJML cannot be compiled on Cloudflare Workers.
// Compile MJML before it reaches the send path: run `mailysend templates push`,
// which compiles it locally and uploads the HTML, or add an MJML build step in
// CI and store the compiled HTML on the template version.

Either way the send path receives plain HTML and nothing on the hot path depends on a Node-only dependency. If you are starting a template from scratch and want MJML’s layout guarantees without its runtime, the AST engine was designed for exactly this constraint.

THE MERGE PASS RUNS AFTER COMPILATION, NOT BEFORE
MJML output is ordinary HTML that may still carry merge tags, so handlebars runs on the compiled result rather than on the source. The order is forced: MJML’s own parser chokes on a {{#if}} wrapped around an <mj-column>, because that is not valid MJML. Write conditionals around the HTML MJML produces, not around MJML’s own tags.

05What happens to your HTML afterwards

Whichever engine produced it, the HTML then goes through a fixed post-render pipeline before it becomes a MIME message.

TL;DR

None of these steps is optional decoration. Each one exists because email clients are not browsers.

StepWhat it doesWhy it has to
inlineCssFlattens a <style> block into style attributes on the elements it matched. Runs only when there is a style block, and reports selectors it could not handle as warnings rather than dropping them silently.Gmail strips head styles in several contexts and older clients never supported them, so a design that relies on a stylesheet arrives unstyled.
ensureTableLayoutStamps the attributes every layout table needs: cellpadding, cellspacing and role="presentation".cellpadding and cellspacing default to non-zero in Outlook and older Gmail — precisely where the mystery two-pixel gaps in a sliced hero image come from. role="presentation" stops a screen reader announcing layout scaffolding as a data table.
injectTrackingAdds the open pixel and rewrites links, with three deliberate exemptions.See the exemptions below — each one is a link that would break if it were signed into the click tracker.
hasUnsubscribeThe pre-send check: does the body already resolve to an unsubscribe, through a placeholder or the literal word. Both {{unsubscribe_url}} and the Mailchimp-era %unsubscribe_url% are recognised, because senders migrate and paste.If nothing is found a default footer is appended before the closing body tag, rather than the message going out without one.
htmlToTextDerives the plain-text part when you have not supplied one, and records that it did so as a warning.A multipart message with a real text alternative is treated better by filters than an HTML-only one, and there is a population of readers who see only that part.
ThresholdWarningWhat it costs you
~102 KB of rendered bodyGmail clippingGmail shows a “View entire message” link and hides everything after the cut — including, in practice, your unsubscribe footer and your open pixel, so a clipped broadcast simultaneously under-reports opens and over-reports complaints.
150 characters of subjectSubject too longMost clients show fewer than eighty. The subject is rendered as plain text rather than HTML, since it ends up in a MIME header where &amp;amp; would be shown to the recipient literally.

Both are warnings on the render result. Wire them into your publish check rather than reading them by eye.

THE HEADERS GO ON EVERY MESSAGE, INCLUDING TRANSACTIONAL
Separately from the body, the send path attaches List-Unsubscribe — an HTTPS one-click endpoint and a mailto: fallback — plus List-Unsubscribe-Post: List-Unsubscribe=One-Click, to every message. Yes, including receipts and password resets. Gmail and Yahoo call that endpoint directly with no human present, which is why it returns plain text with no form, no redirect and no confirmation screen. A visible link in the body is still your call, and is still what a reader actually looks for — the unsubscribe guide covers the rest.

What just happened

You can now pick an engine on purpose rather than by default: the AST when a template needs to be edited by someone who is not you, handlebars when it is a string with names in it, MJML when you already have MJML and a build step, raw HTML when the body came from somewhere else and must not be touched. The thing most likely to bite you is that {{{value}}} escapes exactly like {{value}} here. That is a deliberate divergence from upstream handlebars, it is documented rather than discovered, and a warning is emitted so you find out from the render result instead of from a recipient.

Common questions

Read next

YOUR ACCOUNT, YOUR MAIL

Nothing to sign up for. Just deploy it.

Every guide on this site describes software you run yourself.