DEV Community

Cover image for I love MJML — I just didn't want a whole templating engine for two tiny things
Michal Král
Michal Král

Posted on

I love MJML — I just didn't want a whole templating engine for two tiny things

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:

  1. Variables — drop a name, a URL, an order number into the email.
  2. 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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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] });
Enter fullscreen mode Exit fullscreen mode

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 install @checkthiscloud/mjml-i18n mjml
Enter fullscreen mode Exit fullscreen mode

Sometimes the feature you want isn't "more" — it's exactly two small things and nothing else.

Top comments (0)