How i hacked React.email
My email provider only speaks Handlebars. I wanted to keep writing in React. So I turned React into a compile target and shipped react-to-handlebars. Author in JSX, output real Handlebars.
I love writing emails with React.email. Components, TypeScript, a live preview that actually looks like the thing you’re building, etc. It’s a much better developer experience than editing raw HTML or a proprietary template language. So when I found out the email provider for one of my projects (Mandrill) only accepts Handlebars templates, I didn’t want to give up that DX. I wanted both: author in React, ship in Handlebars. Apparently something like that doesn't exist yet, so I built a bridge between the two.
Here’s how it works and why I built it.
The constraint
Mandrill (and a lot of transactional email stacks) expect Handlebars: {{name}}, {{#if condition}}, {{#each items}}. That’s the contract. I couldn’t change the provider, so the output had to be real Handlebars. But I didn’t want to leave the React ecosystem. I wanted to keep using React Email-style components, shared layout, and a single source of truth that could be previewed in the browser and then compiled to whatever the provider needs.
So the goal was clear: write once in React/JSX, compile once to Handlebars.
The idea: React as the authoring layer
The core idea was to stop treating React as the runtime. Instead, I’d use React only as the authoring layer. At build time, I’d run the component tree once, capture the structure and the place where data goes, and then turn that into a Handlebars template. No React at send-time, just static Handlebars that Mandrill (or any Handlebars-based handler) can render with its own data.
To make that possible, I had to solve two things:
Mark where data goes so the compiler knows which bits become
{{variable}}or{{#if}}or{{#each}}.Preserve that information through React’s render so after
renderToStaticMarkup, I could still tell “this was a condition” vs “this was a variable” vs “this was static HTML.”
So I introduced a small set of “Handlebars helper” components: Handlebars.If, Handlebars.Each, Handlebars.Else, and Handlebars.Val. In React, they do double duty:
In development (preview): They read from a React context and behave like real conditionals and loops. You see real UI with real (or preview) data.
In the build: An env flag flips them into “marker mode.” They don’t evaluate logic anymore; they emit custom elements like
<hb-if condition="items.length">,<hb-each array="items">, and literal{{path}}text. So the structure of the template is preserved in the HTML thatrenderToStaticMarkupoutputs.
How the build actually works
The build pipeline is straightforward in concept, fiddly in details:
Bundle the component with esbuild, with
process.env.IS_HANDLEBARS_BUILD = "true"so the Handlebars helpers emit markers instead of real logic.Invent placeholder data from
PreviewProps. For each component we walk itsPreviewProps(or a default) and replace every prop value with a unique string like__PROP_user_name__. We then render the component with that “marker” props object. So the resulting HTML has those markers exactly where variables will go in Handlebars (e.g.Hello, __PROP_user_name__).Replace markers with Handlebars. We swap each
__PROP_*__back to the right Handlebars path ({{userName}},{{this.title}}inside an each, etc.).Turn custom elements into Handlebars blocks. Regex (and a bit of care for nesting) finds
<hb-if>,<hb-each>,<hb-else>and converts them to{{#if ...}} ... {{/if}},{{#each ...}} ... {{/each}},{{else}}. After that, we have a valid Handlebars template.Write it out. One
.handlebarsfile per React component, next to the source. The rest of the stack (e.g. Mandrill) never sees React; it only sees Handlebars.
So React (and React Email, if you use it) is used only to generate the Handlebars file. The consumer’s React is used for that single render (we resolve React from the app’s node_modules so we don’t get two React instances and weird “invalid React child” errors). After the build, the template is pure Handlebars.
What you get
One codebase. You write the email in React/JSX (optionally with React Email). One place to change copy, layout, and logic.
Preview that matches reality. The same components render in the browser with
Handlebars.Provider(or withPreviewPropsin the build) so you see real conditionals and loops.TypeScript and components. Reuse layout, headings, buttons; type your props and preview data.
Provider-agnostic output. The emitted
.handlebarsfiles work with Mandrill, SendGrid, or any engine that speaks Handlebars. You’re not locked into a single provider.
I published this package as react-to-handlebars: a small runtime (the Handlebars.* components and context) plus a CLI that compiles a directory of React components to Handlebars. So you can “hack” React.email (or plain React) into a Handlebars pipeline without leaving the React ecosystem and still get the DX of components and a live preview, with the output your email provider actually expects.
Related Project(s)
React to Handlebars
Convert React components to Handlebars templates.