I love MJML.
Responsive HTML email is one of the genuinely miserable corners of frontend — nested tables, <!--[if mso]> incantations, clients from 2007 that still get a vote. MJML takes all of that and lets me write semantic-ish components (<mj-section>, <mj-column>, <mj-button>) that compile down to email HTML that actually renders everywhere. It's one of those tools that makes a horrible problem boring, which is the highest compliment I can give a library.
So I was happy. Except for one thing.
The one thing I was missing
My templates needed two unglamorous things:
- Variables — drop a name, a URL, an order number into the email.
-
Translations — the same template in
en,cs, whatever.
MJML doesn't do either, and fair enough — it's a markup compiler, not a template engine. So like a lot of people, I reached for the template engine I already had and ran my .mjml files through Twig first, just to get this:
<mj-text>{{ 'welcome.hello'|trans({'%name%': name}) }}</mj-text>
And it worked. But look at what that actually is: I'm spinning up an entire templating engine — with loops, conditionals, filters, its own parsing pass — to do string interpolation and a dictionary lookup. Two compile steps (Twig → MJML) for what should be one. A second syntax in my files. And every time I touched a template I had to keep two mental models in sync.
I didn't want a template engine in my email pipeline. I wanted those two tiny things — and nothing else.
What I actually wanted
Co-located translations, vue-i18n style — the strings living right next to the markup that uses them — plus dead-simple variable access. Something like:
<mjml>
<i18n type="json">
{
"en": { "hello": "Hello {name}!", "cta": "Shop now" },
"cs": { "hello": "Ahoj {name}!", "cta": "Nakupovat" }
}
</i18n>
<mj-body>
<mj-section>
<mj-column>
<mj-text>{{ i18n('hello', { name: get('firstName') }) }}</mj-text>
<mj-button href="{{ get('url') }}">{{ i18n('cta') }}</mj-button>
</mj-column>
</mj-section>
</mj-body>
</mjml>
No second template language. No second compile. Just i18n() and get().
So I built it: @checkthiscloud/mjml-i18n.
Using it
It's a single MJML preprocessor:
import mjml from 'mjml';
import { createI18nPreprocessor } from '@checkthiscloud/mjml-i18n';
const preprocessor = createI18nPreprocessor({
locale: 'cs',
vars: { firstName: 'Ada', url: 'https://example.com' },
});
const { html } = await mjml(template, { preprocessors: [preprocessor] });
That's the whole integration. One preprocessor in, fully resolved email HTML out.
How it works (and what it deliberately doesn't do)
The trick is that it runs as an MJML preprocessor — it resolves the {{ … }} markers on the raw XML, before MJML parses it. Two nice consequences:
- Markers work anywhere, including inside attributes (
href="{{ get('url') }}"), not just text nodes. - It composes with MJML instead of fighting it.
Inside each {{ … }} is a single function-call expression, evaluated by a tiny allowlist (built on jsep). Only literals, object arguments, and the registered functions (i18n, get) are allowed. You can nest them — i18n('hello', { name: get('firstName') }) — and that's it.
What it is not is a template engine. No loops, no conditionals, no arbitrary expressions, no eval. If a marker isn't a recognized call, it's left untouched rather than executed. That constraint is the whole point: I wanted the two things I was missing, not a Turing-complete language living in my emails.
Translations fall back gracefully (missing key → the key, missing variable → an obvious placeholder), so a typo degrades instead of exploding mid-render.
Status
It's young — 0.x, MIT, built for MJML v5 — but small and focused, and it does exactly what it says. Params are currently plain {name} substitution (no ICU plurals yet, though the seam is there if that day comes). If you live in MJML and you've been bolting a template engine onto it just for translations, give it a spin and tell me where it breaks.
- npm: https://www.npmjs.com/package/@checkthiscloud/mjml-i18n
- GitHub: https://github.com/CheckThisCloud/mjml-i18n
npm install @checkthiscloud/mjml-i18n mjml
Sometimes the feature you want isn't "more" — it's exactly two small things and nothing else.
Top comments (0)