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.
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.
- Strip Unsupported CSS: Remove
@mediaqueries targeting legacy clients (Outlook 2003/2007, Lotus Notes). MJML handles responsive breakpoints natively via<mj-column>width attributes. - 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>. - Flatten Table Depth: Reduce nesting to a maximum of 3 levels. Use a headless DOM parser (
cheerioorjsdom) 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)); " - Remove Spacer Assets: Delete 1x1 transparent GIFs and empty
<td>cells used for spacing. MJML usespaddingand<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
widthandheightexplicitly to prevent layout shifts in Gmail/Apple Mail. - Backgrounds: Replace inline
background-imageCSS 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 affectmj-styleinheritance andmj-rawplacement.
Payload & Rendering Optimization
- 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 - 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. - 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:
- Outer shell first. Map the centring wrapper and content-width table to
mj-body width="600px"plus the firstmj-section. Confirm it compiles before touching anything inside. - Identify repeated blocks. Headers, footers, and CTA rows almost always recur across templates. Extract each into a partial under
partials/and pull it in withmj-includeso one edit propagates everywhere. - Promote inline styles to tokens. Collect the recurring
font-family,font-size, andcolorvalues intomj-attributes(ormj-classfor variants), aligning with the MJML Component Architecture standards. - 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-widthdivs and ghost tables don't map 1:1 — MJML's column model assumes a fixedmj-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 withpaddingonmj-section/mj-columnormj-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 tomj-section background-urlso 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 insidemj-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 inmj-rawand 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.gifor empty spacing<td>remains in the.mjmlsource - All inline
!importantremoved in favour ofmj-attributes/mj-class - Repeated header/footer/CTA blocks extracted to
mj-includepartials - Every
mj-imagehas explicitwidth,height, andalt - Background images moved to
mj-section background-url(VML verified in Outlook) - Dynamic merge syntax wrapped in
mj-raw; build runs withvalidationLevel=strict - Compiled artifact ≤ 102KB after
email-combso the Gmail clip threshold is safe .mjmlsource committed; the converted.htmlis build output, not a tracked file
Related
- MJML Component Architecture — the patterns your converted templates should follow
- MJML vs React Email for transactional systems — confirm MJML is the right destination before migrating
- React Email Development — a JSX path if you prefer components over XML
- Liquid for Shopify Emails — wrap merge logic safely around MJML markup
← Back to MJML Component Architecture