Mailerflow Start free
Blog › Transactional Email & Developer Tools › Developer Email Infrastructure

MJML: Writing Responsive Emails in Code

Published October 17, 2026 · 10 min read

Illustration for MJML: Writing Responsive Emails in Code

MJML is a markup language that compiles to responsive email HTML, and it exists because hand-written table layouts break in ways you cannot predict across Outlook, Gmail, and Apple Mail. You write semantic tags like mj-section, mj-column, and mj-text; the compiler emits the nested tables, conditional comments, and media queries that email clients actually need. This article covers the components worth knowing, how to compile MJML in a build pipeline, how to handle errors and retries, and how to test the output before it reaches an inbox.

What MJML solves, and what it does not

Email HTML is not web HTML. Outlook desktop renders through Word's engine, Gmail strips or rewrites parts of your <head>, and clients disagree about flexbox, media queries, and even basic margin handling. The practical consequence is that a layout that looks fine in a browser can collapse into a single unstyled column in Outlook. MJML absorbs that problem: you describe structure, and the compiler produces the client-specific scaffolding, including Outlook conditional comments and the media queries that make columns stack on narrow screens.

Be clear about scope. MJML handles layout and responsiveness. It does not handle deliverability, sending infrastructure, list management, or content strategy.

The compiled output is ordinary HTML. You can read it, diff it, and hand it to any sending layer. MJML itself is open source and ships a CLI, a Node API, and a VS Code extension, so it fits into the same build tooling you already use for the rest of your code.

Prerequisites

  • Node.js 18 or newer. The examples below target Node 20 LTS.
  • npm or pnpm. Either works; the commands here use npm.
  • A terminal and a browser for previewing compiled output.
  • A way to check real rendering. A browser preview is not enough. Use real inboxes (Gmail, Outlook desktop, Apple Mail, one mobile client) or a rendering service for the long tail.

MJML 4.x is the current major line, and the CLI and the Node API ship in the same mjml package. Pin the version in package.json rather than installing latest, because compiler output can change between minor releases and you want that change to be a deliberate upgrade.

Installing MJML and compiling your first template

For a one-off compile, skip the install:

npx mjml template.mjml -o template.html

For a build you will run repeatedly, install it as a dev dependency:

npm install mjml --save-dev

Here is a complete template you can compile without edits:

<mjml>
  <mj-head>
    <mj-title>Order confirmed</mj-title>
    <mj-preview>Your order is on its way.</mj-preview>
    <mj-breakpoint width="480px" />
    <mj-font name="Inter" href="https://fonts.example.com/inter.css" />
  </mj-head>
  <mj-body background-color="#f4f4f7">
    <mj-section background-color="#ffffff" padding="24px">
      <mj-column>
        <mj-image src="https://cdn.example.com/logo.png" width="160px" alt="Example Store" />
        <mj-text font-family="Inter, Arial, sans-serif" font-size="16px" line-height="24px">
          Hi {{customer.first_name}}, your order {{order.number}} is confirmed.
        </mj-text>
        <mj-button background-color="#1f6feb" href="https://example.com/orders/{{order.id}}">
          View your order
        </mj-button>
      </mj-column>
    </mj-section>
  </mj-body>
</mjml>

The mj-head section is where you set the document title, the preview text that appears next to the subject in most inboxes, the breakpoint at which columns stack, and any web fonts. Everything in mj-body is layout and content.

The components you will actually use

Group components by the job they do rather than memorizing the full list.

Layout: mj-section is a horizontal band; mj-column divides a section into columns; mj-wrapper groups multiple sections so they can share a background; mj-group keeps columns side by side on mobile instead of stacking them.

Content: mj-text, mj-image, mj-button, mj-divider, mj-spacer.

Structured and interactive: mj-table for tabular data, mj-social, mj-navbar, mj-accordion, mj-carousel. The interactive ones depend on CSS support that varies by client, so test them in real inboxes before you commit to them.

A realistic two-column product row uses mj-group when you want the columns to stay side by side on narrow screens:

<mj-section background-color="#ffffff" padding="16px 24px">
  <mj-group>
    <mj-column width="40%">
      <mj-image src="https://cdn.example.com/product.jpg" alt="Product photo" />
    </mj-column>
    <mj-column width="60%">
      <mj-text font-weight="600">Trail Runner 2</mj-text>
      <mj-text>Lightweight, 240g, size 42.</mj-text>
    </mj-column>
  </mj-group>
</mj-section>

Two features save the most repetition. mj-attributes sets defaults once instead of repeating padding on every element, and mj-class defines reusable styles you can apply by name:

<mj-head>
  <mj-attributes>
    <mj-all font-family="Inter, Arial, sans-serif" />
    <mj-text font-size="16px" line-height="24px" color="#1a1a1a" />
    <mj-button background-color="#1f6feb" border-radius="6px" />
  </mj-attributes>
  <mj-class name="muted" color="#6b7280" font-size="14px" />
</mj-head>

Compiling MJML in a build pipeline

There are three levels of integration, and the third is usually the right fit for CI.

CLI in an npm script is the fastest start:

{
  "scripts": {
    "build:email": "mjml src/emails/*.mjml -o dist/emails"
  }
}

The Node API gives you programmatic control. mjml2html returns an object with html and errors:

import mjml2html from 'mjml';

const { html, errors } = mjml2html(source, {
  validationLevel: 'strict',
  minify: true,
  filePath: 'src/emails/order-confirmation.mjml',
});

A build script that walks a directory is what you want in CI. This one compiles every .mjml file, writes HTML, and produces a rough plain-text fallback:

import { readdir, readFile, writeFile, mkdir } from 'node:fs/promises';
import { join, basename } from 'node:path';
import mjml2html from 'mjml';

const SRC = 'src/emails';
const OUT = 'dist/emails';

function toPlainText(html) {
  return html
    .replace(/<style[\s\S]*?<\/style>/gi, '')
    .replace(/<[^>]+>/g, ' ')
    .replace(/&nbsp;/g, ' ')
    .replace(/\s+/g, ' ')
    .trim();
}

async function build() {
  await mkdir(OUT, { recursive: true });
  const files = (await readdir(SRC)).filter((f) => f.endsWith('.mjml'));
  let failed = false;

  for (const file of files) {
    const fullPath = join(SRC, file);
    const source = await readFile(fullPath, 'utf8');
    const { html, errors } = mjml2html(source, {
      validationLevel: 'soft',
      minify: true,
      filePath: fullPath,
    });

    if (errors.length > 0) {
      failed = true;
      for (const err of errors) {
        console.error(`${file}:${err.line} [${err.tagName}] ${err.message}`);
      }
      continue;
    }

    const name = basename(file, '.mjml');
    await writeFile(join(OUT, `${name}.html`), html);
    await writeFile(join(OUT, `${name}.txt`), toPlainText(html));
    console.log(`built ${name}`);
  }

  if (failed) process.exit(1);
}

build().catch((err) => {
  console.error(err);
  process.exit(1);
});

Set filePath whenever you use mj-include, because includes resolve relative to that path. If you omit it, includes fail or resolve against the working directory, which produces confusing errors in CI where the working directory differs from your local machine.

The compiled HTML is what you hand to your sending layer. Mailerflow's transactional email API accepts templates with variables, so you can store the compiled HTML as a template and pass per-send variables rather than rebuilding HTML on every request.

Handling errors and validating templates

mjml2html does not throw on every problem. It returns an errors array, and if you ignore it, broken templates ship silently. Each error carries a line, a message, and usually a tagName.

validationLevel controls the behavior:

  • strict throws on the first error. Use this in CI.
  • soft collects errors and returns them. Use this when you want to report all problems at once.
  • skip ignores validation. Avoid it.

A deliberately broken template:

<mjml>
  <mj-body>
    <mj-section>
      <mj-column>
        <mj-text>Missing closing tag
      </mj-column>
    </mj-section>
  </mj-body>
</mjml>

With strict, this throws with a line number and a message about the unclosed mj-text. With soft, the same information arrives in the errors array and the build script above prints it and exits non-zero.

One subtle failure mode: unknown attributes are silently dropped. If you write paddding="24px" instead of padding, nothing errors and nothing changes, so a typo like this silently changes nothing. Strict validation catches malformed structure, not typos in attribute names, so keep a snapshot test (covered below) to catch unintended output changes.

Idempotency and retries when sending compiled templates

The failure mode to design around: your request times out after the server received it, you retry, and the customer gets two order confirmations. Idempotency keys solve this. Generate a stable key per logical send, such as order-confirmation-10432, send it with the request, and reuse the same key on retry. The provider deduplicates the second attempt.

import { readFile } from 'node:fs/promises';

const apiKey = process.env.MAILERFLOW_API_KEY;

async function sendOrderConfirmation(order, attempt = 1) {
  const html = await readFile('dist/emails/order-confirmation.html', 'utf8');

  const res = await fetch('https://api.mailerflow.ai/v1/emails', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${apiKey}`,
      'Idempotency-Key': `order-confirmation-${order.id}`,
    },
    body: JSON.stringify({
      to: order.customer.email,
      subject: `Order ${order.number} confirmed`,
      html,
      variables: {
        'customer.first_name': order.customer.firstName,
        'order.number': order.number,
        'order.total': order.total,
      },
    }),
  });

  if (res.status >= 500 && attempt < 3) {
    await new Promise((r) => setTimeout(r, 2 ** attempt * 250));
    return sendOrderConfirmation(order, attempt + 1);
  }

  if (!res.ok) {
    throw new Error(`Send failed: ${res.status} ${await res.text()}`);
  }

  return res.json();
}

Two rules make this safe. Retry only on 5xx and network errors, never on 4xx, because a 400 will fail identically on the next attempt. And keep the idempotency key derived from your own data, not from a timestamp, so a retry after a process restart still produces the same key.

Mailerflow's transactional API runs on a high-priority lane and supports templates and variables. The API has an OpenAPI spec and a JS SDK if you would rather not hand-roll fetch. Check the spec for exact request and response field names before wiring this into production, since those are the contract you are coding against. The developer docs are the place to start.

Testing before you ship

Test at four layers, cheapest first.

Snapshots. Compile a fixture template and snapshot the HTML. Any unintended change shows up as a diff in review.

Assertions. Check that the preview text, CTA URL, and unsubscribe link are present, and that no unresolved {{variables}} remain.

Rendering. Send to real inboxes across Gmail, Outlook desktop, Apple Mail, and a mobile client. Use a rendering service for the long tail.

Links and images. Verify absolute URLs, alt text on every image, and explicit widths.

A minimal test with Node's built-in test runner:

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import mjml2html from 'mjml';

test('order confirmation compiles cleanly', async () => {
  const source = await readFile('src/emails/order-confirmation.mjml', 'utf8');
  const { html, errors } = mjml2html(source, {
    validationLevel: 'soft',
    filePath: 'src/emails/order-confirmation.mjml',
  });

  assert.equal(errors.length, 0, JSON.stringify(errors, null, 2));
  assert.match(html, /View your order/);
  assert.match(html, /https:\/\/example\.com\/orders\//);
  assert.doesNotMatch(html, /\{\{[^}]+\}\}/);
});

On accessibility, set a lang attribute on the document, write meaningful alt text, keep contrast ratios high enough to read, and use headings in order rather than for visual size.

A complete example: order confirmation template

This ties the pieces together. The template uses includes for shared chrome, variables for personalization, and a table for line items. Example Store is fictional.

src/emails/partials/header.mjml:

<mj-section background-color="#ffffff" padding="24px">
  <mj-column>
    <mj-image src="https://cdn.example.com/logo.png" width="160px" alt="Example Store" />
  </mj-column>
</mj-section>

src/emails/order-confirmation.mjml:

<mjml>
  <mj-head>
    <mj-title>Order {{order.number}} confirmed</mj-title>
    <mj-preview>Thanks {{customer.first_name}} — your order is confirmed.</mj-preview>
    <mj-attributes>
      <mj-all font-family="Inter, Arial, sans-serif" />
      <mj-text font-size="16px" line-height="24px" color="#1a1a1a" />
    </mj-attributes>
  </mj-head>
  <mj-body background-color="#f4f4f7">
    <mj-include path="./partials/header.mjml" />
    <mj-section background-color="#ffffff" padding="24px">
      <mj-column>
        <mj-text>Hi {{customer.first_name}}, thanks for your order.</mj-text>
        <mj-table>
          <tr>
            <th align="left">Item</th>
            <th align="right">Qty</th>
            <th align="right">Price</th>
          </tr>
          <tr>
            <td>{{item.name}}</td>
            <td align="right">{{item.qty}}</td>
            <td align="right">{{item.price}}</td>
          </tr>
        </mj-table>
        <mj-divider border-color="#e5e7eb" />
        <mj-text font-weight="600">Total: {{order.total}}</mj-text>
        <mj-button href="https://example.com/orders/{{order.id}}">View your order</mj-button>
      </mj-column>
    </mj-section>
  </mj-body>
</mjml>

Compile it with the build script above, then send it with the idempotency pattern from the previous section. The compiled HTML goes into the request body; the variables go alongside it.

Troubleshooting common MJML problems

Columns not stacking on mobile. Cause: a fixed pixel width on mj-column, or mj-group wrapping columns you wanted to stack. Fix: use percentage widths and remove mj-group where stacking is desired.

Styles stripped in Gmail. Cause: unsupported CSS or class-based selectors that Gmail removes. Fix: prefer inline attributes on MJML components over custom classes.

Outlook spacing looks wrong. Cause: padding applied to mj-column instead of mj-section, or a divider rendering as a gap. Fix: move padding up to the section and test in Outlook desktop specifically.

Images not loading. Cause: relative paths. Fix: always use absolute https:// URLs.

Includes fail in CI. Cause: filePath not set, so includes resolve against the wrong directory. Fix: pass the absolute source path to mjml2html.

Output changed after an upgrade. Cause: a new MJML minor version. Fix: pin the version and read the changelog before bumping.

FAQ

Is MJML still maintained and worth using? Yes. It is open source, actively used, and the 4.x line is stable. The main alternative is writing table HTML by hand or using a framework that generates it, and MJML is designed to produce responsive output without you maintaining client-specific hacks yourself.

Does MJML work with React or Vue? Not directly. MJML compiles to HTML at build time, so the usual pattern is to keep templates as .mjml files, compile them in your build, and render the resulting HTML from your app. Some teams wrap the Node API in a build step rather than trying to render MJML as a component.

Can I use MJML with Handlebars or Liquid? Yes. Keep your template variables as literal placeholders in the .mjml source and let your sending layer or template engine substitute them at send time. Just make sure your build does not strip them, and assert in tests that no unresolved placeholders remain.

How do I convert existing HTML to MJML? There is an online converter, but treat its output as a starting point. Hand-written email HTML often contains client-specific hacks the converter cannot interpret, so expect to rebuild layout structure manually and keep only the content.

Does MJML handle dark mode? It does not solve dark mode for you. You can add media queries for prefers-color-scheme in mj-style, but support for prefers-color-scheme differs between clients, so test in the clients your audience uses.

Where Mailerflow fits

Compiled MJML HTML can be stored as a template and sent through the transactional API with per-send variables, which keeps your build pipeline and your sending layer cleanly separated. If you would rather start from a draft than a blank file, the AI email builder generates a complete responsive design, subject lines, and preview text from a description, and you can then move the layout into MJML if you want it under version control.

Next step: compile the order confirmation template above, send it to yourself, and open it in three clients — Gmail, Outlook desktop, and one mobile app. That single loop will surface more real problems than any amount of browser previewing.

Send your first transactional email with the Mailerflow REST API — scoped keys, idempotency and an OpenAPI spec. Start free.