At thoughtbot we use MJML to build emails. If you’ve ever tried to write an HTML email by hand, you’ll know that every email client has its quirks and limitations. The MJML markup language automates much of this away. MJML can be compiled using command line tools or JavaScript library integrations. But most of our team uses the MJML desktop app.
Often our emails will need to include dynamic content like “Hello Dave” or “today you saved $7.34”. We achieve this by using templating tools that replace template tags with variables. For instance SendGrid’s dynamic templates use a version of the Handlebars templating engine. You can use a tag like {{firstName}}
and it will be replaced with the user’s name. AWS’s Simple Email Service also uses Handlebars. Shopify, Auth0, Campaign Monitor and others use the similar Liquid template language.
The problem
MJML is just a formatting tool and doesn’t natively support Handlebars or other template engines. We’ll often plug in template tags after compiling the MJML into HTML. But then we need to re-add the tags every time our MJML changes.
Instead we can add the template tags to the raw MJML. But then it’s tricky to preview what the final email will will look like when the variables are replaced with real values. It’s also hard to debug conditional logic or loops.
We needed a better way to build MJML emails that lets us both preview the email with fake values, but also generate HTML with template tags for SendGrid.
The solution: MJML preprocessors
We managed to achieve our goal using a little known MJML config feature called preprocessors.
First some background on the MJML config file
A normal .mjmlconfig
file is a JSON file that lets you configure MJML. It looks something like this:
{
"packages": [
"component-name/path-to-js-file"
],
"options": {
"fonts": {},
"keepComments": false
}
}
You can add use the .mjmlconfig file when compiling your MJML from the command line and also when using the MJML app.
How to use preprocessors
There’s a preprocessors
option in the MJML config that’s special and not well documented. It only works when you use a JavaScript version of the .mjmlconfig
instead of JSON: .mjmlconfig.js
. This JavaScript version of the file should export a JS object with the same structure as the JSON:
const options = {
preprocessors: [(rawMJML) => processMJML(rawMJML)],
"packages": []
}
module.exports = options
From the MJML docs:
Preprocessors [are] applied to the xml before parsing. Input must be xml, not json. Functions must be
(xml: string) => string
We can configure a JavaScript preprocessor that will receive the MJML markup right before it gets rendered to HTML. If we modify this markup, we can affect the output. A preprocessor function signature looks like this:
(xml: string) => string
The input XML is your raw MJML file (with partials included), and the output is your transformed MJML file, ready to be converted to HTML.
You can probably see where this is going: our preprocessor can run a template engine to replace the variables in the MJML with real data, so we can preview them in the HTML. And when we’re ready to plug the email into SendGrid or SES, we turn off the preprocessor and get HTML with template tags included.
Here’s what we came up with using Handlebars:
const Handlebars = require('handlebars');
const templateData = {
"firstName": "Dave",
"orderId": "A77395",
"savingsAmount": "$7.34",
"orderTime": "8:10 AM on Fri, June 12",
"spaceNumber": 3,
"optionsList": "",
}
const options = {
// rawMJML is the raw MJML XML, as saved in the .mjml files
// the output should be transformed XML - still MJML, with any of our changes made
preprocessors: [(rawMJML) => {
const hbarsTemplate = Handlebars.compile(rawMJML);
const compiledTemplate = hbarsTemplate(templateData);
return compiledTemplate;
}],
"packages": []
}
module.exports = options
To make this preprocessor work with the desktop app you need to change a few settings:
- Set the path to your
.mjmlconfig
file to point to the.mjmlconfig.js
for this email - Turn on “Use custom components”
And voila - your MJML editor uses template tags, but the preview pane shows actual content
Debugging
Sometimes you screw up your template tags. If there’s an error in your preprocessor, MJML will render an empty page. If you’re using the MJML app you can make it a little easier on yourself by showing some debug information.
function handlebarsErrorMJML(error) {
return `
<mjml><mj-body><mj-section><mj-column>
<mj-text>Handlebars encountered an error:</mj-text>
<mj-text>${error.message}</mj-text>
</mj-column></mj-section></mj-body></mjml>
`;
}
const options = {
preprocessors: [(rawMJML) => {
try {
const hbarsTemplate = Handlebars.compile(rawMJML);
const compiledTemplate = hbarsTemplate(templateData);
return compiledTemplate;
} catch (e) {
console.error(e);
return handlebarsErrorMJML(e)
}
}],
"packages": []
}
Now your output will show exactly what went wrong.
You might need some workaround for MJML. The app doesn’t update the preview until you type something. And your Handlebars loops and conditional statements need to go inside HTML comments unless they’re in an <mj-text>
element: (<!-- {{#each bookings}} -->
).
Where to go from here?
We used Handlebars, but there’s no reason Liquid or other templating tools won’t work. If you want to use a non-JavaScript engine like Ruby ERB templates or ASP.NET Razor templates, try using Node’s exec
to shell out to them.
If you prefer to compile your templates programmatically instead of using the MJML app, you can use the MJML NPM package. It works with preprocessors too.
import mjml2html from 'mjml';
function processMJMLTemplate(mjml) { ... }
const htmlOutput = mjml2html(rawMJML, {
preprocessors: [processMJMLTemplate]
});
My team has started using this MJML + Handlebars workflow for a few different emails. It’s faster to make template updates, and we’re much more confident that dynamic fields will get filled in exactly how we want. I’d say that’s a win.