foldout

MJML basics: from readable source to an email Outlook renders

MJML lets you write an email as a dozen readable tags and compiles them into the tables, VML and conditional comments that hold together in Outlook. Here is the whole workflow, with what the compiler is doing on your behalf.

The problem MJML solves

Desktop Outlook renders email with the Word engine. Flexbox, grid, margins on divs, background images — gone or unreliable. The structure that survives is nested tables, with VML for rounded buttons and conditional comments to feed Outlook its own markup. Writing that by hand is possible and miserable. MJML is a small language that compiles to it.

The smallest email

hello.mjml
<mjml>
<mj-head>
  <mj-title>Welcome</mj-title>
  <mj-preview>Your account is ready.</mj-preview>
</mj-head>
<mj-body background-color="#f2f4f7">
  <mj-section background-color="#ffffff" padding="40px 32px" border-radius="12px">
    <mj-column>
      <mj-text font-size="24px" font-weight="700">Welcome, {{first_name}}</mj-text>
      <mj-text>Your account is ready. One thing to do first:</mj-text>
      <mj-button href="{{cta_url}}">Open your workspace</mj-button>
    </mj-column>
  </mj-section>
</mj-body>
</mjml>

Four ideas: mj-body is the canvas, mj-section a horizontal band (a table row), mj-column a column inside it (a table cell), and content tags — text, button, image, divider — inside columns. That mj-button becomes a table with a VML rectangle for Outlook and an anchor for everyone else; you didn’t write either.

Compile

three commands
npm install mjml
npx mjml src/hello.mjml -o dist/          # once
npx mjml -w src/*.mjml -o dist/            # watch while editing

Open dist/hello.html: a 600-px table centred with a table, inline styles on every cell, <!--[if mso]> blocks around the parts Outlook needs. Paste that into your sending tool.

Two columns

columns stack on phones by default
<mj-section>
<mj-column width="50%"><mj-image src="…" /></mj-column>
<mj-column width="50%"><mj-text>Caption</mj-text></mj-column>
</mj-section>

On desktop, side by side; under 480 px, stacked. MJML writes the media query and the Outlook-only <table> wrapper that keeps the columns aligned there. Hand-written, this is about forty lines.

A shared theme file

Fonts, colours and the button style belong in one place. mj-include pulls a file into mj-head; put attributes in mj-attributes and every tag inherits them.

src/_theme.mjml
<mj-attributes>
<mj-all font-family="Helvetica, Arial, sans-serif" />
<mj-text font-size="16px" line-height="1.5" color="#111826" />
<mj-button background-color="#1f4fff" color="#ffffff" border-radius="8px" font-weight="700" />
</mj-attributes>
<mj-style>
@media (prefers-color-scheme: dark) { .card-bg, .card-bg > table { background: #161b22 !important; } }
</mj-style>
in every email
<mj-head>
<mj-include path="./_theme.mjml" />
</mj-head>

The mj-style block is where dark-mode rules go. Note the selector: MJML nests a <table> inside the section <td>, so the rule has to reach .card-bg > table too, or the card stays white in dark inboxes.

Merge tags and the sending tool

MJML doesn’t care what {{first_name}} is; it passes it through. Most providers use that syntax or something close. Keep the tags on their own — don’t put them inside attributes the compiler transforms — and do a find-and-replace if your provider wants *|FNAME|*.

What to check in the output

  • Width 600. mj-body width="600px" is the default and the safe one.
  • No <style> your provider will strip. Gmail keeps <style> in <head>; some tools inline everything and drop media queries. Test a send.
  • Images with alt and explicit width. Outlook shows the alt text until images load and sizes by the attribute, not CSS.
  • A plain-text alternative. Your sending tool makes one; read it once.
  • Size under 102 KB. Gmail clips larger messages. A three-column newsletter with a theme file compiles to about 30 KB.

If you’d rather start from working source: the Welcome series is four MJML emails with a shared theme, compiled HTML beside them, and a preview page that shows each one in a light and a dark inbox.

Tell us what happened

Goes straight to a person. No account, no ticket number.

What is this about?

We keep the message and the page it came from. Nothing else — not your IP, not your browser. Privacy.