Skip to main content

Converting HTML Emails to MJML Components: Implementation Workflow

Step-by-step workflow to migrate legacy HTML email templates to MJML components with rendering validation and automated testing.

Table soup to MJML components The left panel shows deeply nested legacy table markup; the right panel shows the equivalent flat MJML component structure. Table Soup → MJML Components Legacy HTML <table><tr><td> <table><tr><td> <table><tr><td> <!--[if mso]>… style="!important" spacer.gif 1x1 </td></tr></table>… brittle, deeply nested MJML Components <mj-section> <mj-column> <mj-text> <mj-image> </mj-column> </mj-section> flat, ≤ 3 levels deep VML auto-injected for Outlook
Migration collapses hand-tuned nested tables and MSO comments into a flat MJML component tree that the compiler expands back to safe HTML.

Why Migrate from Table-Based HTML to MJML

Legacy email markup depends on deeply nested <table> structures, client-specific conditional comments (<!--[if mso]>), and aggressive inline !important overrides. MJML replaces this with a declarative, component-driven abstraction layer that compiles to optimized, cross-client compatible HTML. Transitioning to Modern Email Templating Engines eliminates brittle layout inheritance, standardizes responsive breakpoints, and reduces maintenance overhead across transactional and marketing pipelines.

Pre-Conversion DOM Sanitization

Raw HTML must be normalized before MJML mapping. Unsanitized markup triggers parser failures and bloats final payloads.

  1. Strip Unsupported CSS: Remove @media queries targeting legacy clients (Outlook 2003/2007, Lotus Notes). MJML handles responsive breakpoints natively via <mj-column> width attributes.
  2. Isolate Dynamic Logic: Extract template variables ({{ }}, {% %}, #if) into a separate configuration file. MJML's compiler will strip unrecognized tags unless explicitly wrapped in <mj-raw>.
  3. Flatten Table Depth: Reduce nesting to a maximum of 3 levels. Use a headless DOM parser (cheerio or jsdom) to audit structural complexity:
    node -e "
    const cheerio = require('cheerio');
    const html = require('fs').readFileSync('legacy.html','utf8');
    const \$ = cheerio.load(html);
    const depths = Array.from(\$('table')).map(el => \$(el).parents('table').length);
    console.log('Max table depth:', Math.max(...depths));
    "
  4. Remove Spacer Assets: Delete 1x1 transparent GIFs and empty <td> cells used for spacing. MJML uses padding and <mj-spacer> for predictable layout control.

Step-by-Step Implementation Pipeline

Step 1: Map Layout to MJML Primitives

Translate table semantics directly to MJML hierarchy. Maintain strict XML compliance.

<!-- Legacy HTML -->
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="background:#f4f4f4;">
  <tr>
    <td align="center" style="padding: 24px 0;">
      <table width="600" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;">
        <tr><td style="padding: 20px; font-family: sans-serif;">Header Content</td></tr>
      </table>
    </td>
  </tr>
</table>

<!-- MJML Equivalent -->
<mjml>
  <mj-body background-color="#f4f4f4">
    <mj-section padding="24px 0">
      <mj-column width="600px" background-color="#ffffff">
        <mj-text padding="20px" font-family="sans-serif">Header Content</mj-text>
      </mj-column>
    </mj-section>
  </mj-body>
</mjml>

Apply mj-class for reusable styling to enforce DRY principles and align with MJML Component Architecture standards.

Step 2: Handle Conditional Logic & Assets

MJML does not process server-side templating natively. Wrap dynamic blocks in <mj-raw> to bypass the compiler's XML parser:

<mj-raw>
  {% if user.tier == 'enterprise' %}
</mj-raw>
<mj-section>
  <mj-column>
    <mj-text>Enterprise Feature Block</mj-text>
  </mj-column>
</mj-section>
<mj-raw>
  {% endif %}
</mj-raw>
  • Images: Use absolute URLs only. Specify width and height explicitly to prevent layout shifts in Gmail/Apple Mail.
  • Backgrounds: Replace inline background-image CSS with <mj-section background-url="...">. MJML automatically generates VML fallbacks for Outlook.

If the source template carries platform-specific merge syntax, isolate that logic first by following the dynamic-data patterns in Liquid for Shopify Emails so the MJML parser never sees an unbalanced tag.

Step 3: Compile & Optimize

Run the MJML CLI with production flags. Disable beautification to reduce whitespace overhead:

npx mjml input.mjml -o output.html --config.beautify false --config.minify true

To validate MJML syntax before compiling, use validationLevel:

npx mjml input.mjml -o output.html --config.validationLevel=strict

Debugging Rendering Discrepancies

Error Handling & Validation

  • XML Syntax Errors: MJML compilation halts on unclosed tags or invalid attributes. Pre-validate with xmllint:
    xmllint --noout input.mjml 2>&1 | grep -i "error" && exit 1
  • Parser Stripping: If dynamic tags disappear in output, verify they are strictly enclosed in <mj-raw>. MJML v4+ enforces strict XML parsing.
  • CLI Version Mismatch: Pin dependencies in package.json ("mjml": "^4.15.0"). Breaking changes between major versions affect mj-style inheritance and mj-raw placement.

Payload & Rendering Optimization

  1. Size Audit: Transactional providers (SendGrid, SES, Postmark) enforce strict payload limits. Keep compiled HTML ≤ 102KB. Strip unused CSS classes post-compilation using email-comb:
    npx email-comb output.html -o optimized.html
  2. Outlook VML Conflicts: MJML auto-generates VML for backgrounds. If custom VML is required, inject raw VML manually inside <mj-raw> blocks and disable MJML background generation for that section.
  3. Regression Testing Pipeline:
    # 1. Compile
    npx mjml template.mjml -o template.html --config.minify true
    # 2. Validate HTML
    npx html-validate template.html
    # 3. Diff against baseline (CI)
    git diff --exit-code baseline.html template.html || echo "Rendering drift detected"
    # 4. Push to rendering QA (Litmus API v3)
    curl -X POST https://api.litmus.com/v3/tests \
      -H "Authorization: Bearer $LITMUS_TOKEN" \
      -H "Content-Type: application/json" \
      -d "{\"test_name\":\"regression\",\"html_source\":\"$(cat template.html | tr -d '\n' | sed 's/"/\\"/g')\"}"

Implement automated snapshot testing in CI/CD to catch client-specific breaks before deployment, as covered in the MJML Component Architecture reference. Maintain strict version control for .mjml source files and compiled .html outputs to ensure transactional delivery stability.

A Fuller Before/After Conversion

The Step 1 snippet covered a single wrapper. Real migrations face a full header-body-CTA block with spacer GIFs, MSO conditionals, and inline !important. Here is a representative legacy fragment and its complete MJML equivalent.

<!-- Legacy: nested tables, spacer.gif, MSO ghost table, inline !important -->
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="background:#f4f4f4;">
  <tr><td align="center">
    <!--[if mso]><table width="600"><tr><td><![endif]-->   <!-- Outlook 2016-2021 ghost wrapper -->
    <table width="600" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;">
      <tr><td style="padding:0 0 24px 0 !important;">
        <img src="https://cdn.example.com/logo.png" width="160" alt="Acme">
      </td></tr>
      <tr><td><img src="spacer.gif" width="1" height="16" alt=""></td></tr> <!-- spacer hack -->
      <tr><td style="font-family:Arial,sans-serif;font-size:16px;color:#333;">Welcome back.</td></tr>
      <tr><td align="center" style="padding:24px 0;">
        <!--[if mso]>VML roundrect omitted for brevity<![endif]-->
        <a href="https://app.example.com" style="background:#A53860;color:#fff;padding:12px 24px;">Open app</a>
      </td></tr>
    </table>
    <!--[if mso]></td></tr></table><![endif]-->
  </td></tr>
</table>
<!-- MJML: flat, no spacer GIF, MSO ghost table + VML button auto-injected by the compiler -->
<mjml>
  <mj-head>
    <mj-attributes>
      <mj-text font-family="Arial, sans-serif" font-size="16px" color="#333333" />
    </mj-attributes>
  </mj-head>
  <mj-body width="600px" background-color="#f4f4f4">
    <mj-section background-color="#ffffff" padding="0 0 24px 0">
      <mj-column>
        <mj-image src="https://cdn.example.com/logo.png" alt="Acme" width="160px" />
        <mj-spacer height="16px" />               <!-- replaces spacer.gif; no transparent image needed -->
        <mj-text>Welcome back.</mj-text>
        <!-- mj-button emits the <!--[if mso]--> VML roundrect Outlook 2016-2021 requires -->
        <mj-button href="https://app.example.com" background-color="#A53860" color="#ffffff">Open app</mj-button>
      </mj-column>
    </mj-section>
  </mj-body>
</mjml>

Note what disappeared: the hand-written MSO ghost table (MJML regenerates it around every section), the spacer.gif (now mj-spacer), the inline !important (token inheritance via mj-attributes), and the manual VML button (emitted by mj-button). The migration is largely deletion — you are removing defensive hacks the compiler now owns. This is the same fallback machinery documented in the Outlook rendering fixes reference, just generated deterministically instead of pasted by hand.

Component-Extraction Strategy

Convert in passes rather than top-to-bottom in one sitting. A repeatable order keeps each diff reviewable:

  1. Outer shell first. Map the centring wrapper and content-width table to mj-body width="600px" plus the first mj-section. Confirm it compiles before touching anything inside.
  2. Identify repeated blocks. Headers, footers, and CTA rows almost always recur across templates. Extract each into a partial under partials/ and pull it in with mj-include so one edit propagates everywhere.
  3. Promote inline styles to tokens. Collect the recurring font-family, font-size, and color values into mj-attributes (or mj-class for variants), aligning with the MJML Component Architecture standards.
  4. Convert leaf content last. Text, images, and buttons are the simplest mappings — do them only after the structure compiles cleanly, so a parser error is always localized to the row you just changed.
# Detect repeated blocks worth extracting into partials before you start converting
node -e "
const cheerio = require('cheerio');
const \$ = cheerio.load(require('fs').readFileSync('legacy.html','utf8'));
const sigs = {};
\$('table').each((_, el) => { const s = \$(el).find('td').length + 'td'; sigs[s] = (sigs[s]||0)+1; });
console.log('Table shapes by frequency:', sigs); // shapes seen 2+ times are partial candidates
"

Conversion Edge Cases

  • Hybrid/fluid layouts. Templates built with max-width divs and ghost tables don't map 1:1 — MJML's column model assumes a fixed mj-body width. Pick the design width (usually 600px) and let MJML handle the responsive collapse; don't try to preserve the original fluid math.
  • Hand-tuned Outlook spacing. If the legacy markup uses empty <td height="x"> cells (a common Outlook table spacing fix), replace them with padding on mj-section/mj-column or mj-spacer. Outlook 2016-2021 respects MJML's generated MSO line-height padding.
  • Background images. Inline background:url() on a <td> won't render in Outlook. Move it to mj-section background-url so the compiler injects the VML <v:rect> fill for Outlook 2007-2019.
  • Dark mode overrides. Hand-coded @media (prefers-color-scheme: dark) blocks survive only inside mj-style; loose <style> in the legacy <head> is dropped. Re-home them and verify against the dark mode email CSS patterns.
  • Merge syntax mid-tag. If a {{ }} or {% %} token sits inside an attribute the MJML parser validates, the build can fail. Isolate control flow in mj-raw and keep merge tags in attribute values, not attribute names.

Transactional-Pipeline Integration

A converted template is not finished until it slots into the send path the same way the legacy HTML did. The order is strict: render dynamic data with your templating engine, then compile MJML, then dispatch — never compile inside the send call, because re-inlining after the ESP merges variables corrupts the MSO conditional comments and squares your Outlook buttons.

// Convert once at build/deploy; compile per-send is wasted CPU and risks Outlook drift.
const fs = require('fs');
const nunjucks = require('nunjucks');
const mjml2html = require('mjml');

function buildWelcome(data) {
  // step 1: interpolate {{ }} into the converted .mjml BEFORE the compiler runs
  const src = nunjucks.render('templates/welcome.mjml', data);
  // step 2: compile; fail loudly so a bad migration never reaches Amazon SES
  const { html, errors } = mjml2html(src, { validationLevel: 'strict', filePath: 'templates/welcome.mjml' });
  if (errors.length) throw new Error(errors.map(e => e.formattedMessage).join('\n'));
  return html; // step 3: hand this string to SES/SendGrid/Postmark as the HTML body
}

Wire the conversion into CI so a regression in the migrated markup blocks the merge: compile every .mjml, snapshot the output (see Jest snapshot testing for MJML templates), and diff against the baseline so unintended structural drift surfaces in PR review rather than in a customer's inbox. Pre-render the HTML at deploy time and cache it; the SES/SendGrid/Postmark call should only ever interpolate per-recipient merge fields into already-compiled HTML, never invoke the MJML compiler on the hot path.

Post-Conversion Validation Checklist

  • Compiled HTML diffs cleanly against a Litmus/Email on Acid render of the original in Gmail, Outlook 2016/2019/365, Apple Mail, iOS Mail, and Samsung Email
  • No spacer.gif or empty spacing <td> remains in the .mjml source
  • All inline !important removed in favour of mj-attributes/mj-class
  • Repeated header/footer/CTA blocks extracted to mj-include partials
  • Every mj-image has explicit width, height, and alt
  • Background images moved to mj-section background-url (VML verified in Outlook)
  • Dynamic merge syntax wrapped in mj-raw; build runs with validationLevel=strict
  • Compiled artifact ≤ 102KB after email-comb so the Gmail clip threshold is safe
  • .mjml source committed; the converted .html is build output, not a tracked file

← Back to MJML Component Architecture