MJML Component Architecture: Implementation Patterns & Rendering Workflows
Design scalable MJML component systems—section patterns, responsive grids, and cross-client rendering workflow best practices.
Core Architecture & XML Abstraction Layer
MJML operates as a declarative, XML-based abstraction layer that compiles into highly compatible, table-driven HTML. Unlike modern web frameworks that rely on CSS Grid or Flexbox, MJML enforces a strict component hierarchy optimized for legacy email clients. The compiler pipeline (mjml CLI / mjml-core) parses semantic tags and outputs nested <table> structures, automatically inlines CSS, and generates VML fallbacks where required.
The foundational tag hierarchy maps directly to email layout primitives:
<mjml>
<mj-head>
<mj-attributes>
<mj-text font-family="Arial, sans-serif" font-size="14px" color="#333333" />
</mj-attributes>
<mj-style>
/* Client-specific overrides */
.dark-mode a { color: #ffffff !important; }
</mj-style>
</mj-head>
<mj-body background-color="#f4f4f4">
<mj-section padding="20px 0">
<mj-column width="100%">
<mj-text>Hello, World</mj-text>
</mj-column>
</mj-section>
</mj-body>
</mjml>
Production Pattern: When scaling Modern Email Templating Engines, treat <mj-attributes> as your design token registry. Global attribute inheritance reduces payload size and prevents CSS duplication across transactional templates.
Debugging Steps:
- Run
npx mjml template.mjml --config.minify false --config.beautify trueto inspect compiler output with readable formatting. - Add
--config.validationLevel=strictto surface warnings about unknown attributes or tag placement. - If layout breaks in Apple Mail, check for unclosed
<mj-column>tags; MJML's parser silently drops malformed children.
Component Composition & Modular Workflows
Modularization in MJML relies on <mj-include> directives and programmatic component registration. Teams should isolate headers, footers, and CTA blocks into reusable .mjml files, then compose transactional payloads dynamically.
# Directory structure
/templates/
├── base.mjml
├── partials/
│ ├── header.mjml
│ └── footer.mjml
└── transactional/
├── order-confirmation.mjml
└── password-reset.mjml
Custom Component Registration:
Extend the compiler to enforce brand constraints or inject tracking pixels at parse time:
const mjml2html = require('mjml');
const { registerComponent, MJMLElement } = require('mjml-core');
class MjBrandFooter extends MJMLElement {
static endingTag = false;
static allowedAttributes = {
'tracking-id': 'string',
'legal-text': 'string'
};
render() {
const trackingId = this.getAttribute('tracking-id');
return `
<mj-text align="center" font-size="11px" color="#999999">
${this.getAttribute('legal-text')}
<img src="https://track.example.com/pixel?id=${trackingId}" width="1" height="1" alt="" />
</mj-text>
`;
}
}
registerComponent(MjBrandFooter);
Legacy Migration: When refactoring legacy markup, follow a systematic approach for Converting HTML emails to MJML components by isolating table structures into <mj-section> wrappers and replacing inline style attributes with <mj-attributes> inheritance.
Debugging Steps:
- Path Resolution Errors: MJML resolves includes relative to the executing file. Use absolute paths or set the
filePathoption when callingmjml2htmlprogrammatically. - Circular Includes: The compiler throws
Circular dependency detected. Enforce a strict DAG (Directed Acyclic Graph) in your template directory. - Attribute Overwrites: Child components override parent attributes. Use
mj-classto scope overrides without breaking inheritance chains.
Programmatic Integration & CI/CD Pipelines
Headless compilation enables MJML to integrate directly into Node.js backends, serverless functions, and deployment pipelines. The mjml-core API accepts raw XML strings and returns compiled HTML with error metadata.
Node.js Compilation Pipeline:
const fs = require('fs');
const mjml2html = require('mjml');
function compileTemplate(templatePath) {
const rawMjml = fs.readFileSync(templatePath, 'utf8');
const { html, errors } = mjml2html(rawMjml, {
minify: true,
beautify: false,
validationLevel: 'strict',
keepComments: false,
filePath: templatePath // Required for <mj-include> path resolution
});
if (errors.length > 0) {
console.error('MJML Compilation Errors:', errors);
throw new Error('Template compilation failed');
}
return html;
}
Note: mjml-core exports mjml2html as the default export in v4. Import it as const mjml2html = require('mjml') (the mjml package re-exports mjml-core).
CI/CD Integration (GitHub Actions):
- name: Compile & Validate MJML
run: |
npx mjml templates/**/*.mjml --output dist/emails/ --config.validationLevel=strict
env:
NODE_ENV: production
This workflow aligns closely with React Email Development, where JSX components transpile to inline-styled HTML before final output. Both paradigms benefit from pre-rendered, static HTML outputs that bypass client-side JavaScript execution. If you are still deciding between the two approaches for a notification backend, the trade-off analysis in MJML vs React Email for transactional systems compares compile determinism, payload size, and team ergonomics directly.
Debugging Steps:
- Memory Leaks: Large templates (>50KB) compiled synchronously can block the event loop. Process templates in parallel using
Promise.allwith bounded concurrency rather than sequential calls. - Async Data Injection: Never compile MJML after ESP API calls. Pre-render HTML, then attach to
SendGrid,Postmark, orAWS SESpayloads. - Config Drift: Pin
mjmlversions inpackage.json. Patch releases occasionally alter table nesting behavior, breaking Outlook rendering.
Data Binding & Server-Side Rendering Constraints
MJML lacks native templating logic. Data binding requires wrapping MJML in a server-side engine (Nunjucks, Liquid, Handlebars, or Jinja2) before compilation.
Templating Engine Integration (Liquid Example):
<mj-section>
<mj-column>
{% for item in order.items %}
<mj-image src="{{ item.image_url }}" width="100px" alt="{{ item.name }}" />
<mj-text>{{ item.name }} - {{ item.quantity }}x</mj-text>
{% endfor %}
</mj-column>
</mj-section>
For e-commerce platforms, pairing MJML with Liquid for Shopify Emails allows dynamic product grids and order summaries to render safely within MJML's constrained DOM. The compilation order must be: Template Engine → MJML Compiler → HTML Output.
Provider-Specific Rendering Constraints:
| Constraint | Implementation Pattern | Client Impact |
|---|---|---|
| No Flexbox/Grid | Use <mj-column> with width attributes |
Gmail, Outlook 2016+ |
| Inline CSS Mandatory | MJML auto-inlines; avoid <style> outside <mj-style> |
All ESPs |
| VML Backgrounds | <mj-section background-url="..." background-size="cover"> |
Outlook 2007-2019 |
| Media Queries | Wrap in <mj-style> with @media |
iOS Mail, Apple Mail |
| JS/External CSS | Stripped during compilation | Universal |
Outlook VML Fallback Configuration:
<mj-section background-color="#000000" background-url="https://cdn.example.com/bg.jpg" background-repeat="no-repeat" background-size="cover">
<!-- MJML auto-injects VML for Outlook -->
<mj-text color="#ffffff">Fallback content</mj-text>
</mj-section>
Debugging Steps:
- Gmail Clipping: Keep payload under 102KB. Strip whitespace, minify, and remove unused
<mj-style>rules. - Apple Mail Font Rendering: Add
@import url('...')inside<mj-style>for web fonts, but always providefont-family="Arial, sans-serif"fallbacks on components. - Conditional Logic Errors: Template engines may break MJML XML structure if
{% if %}tags split opening/closing tags. Always wrap logic at the component level using<mj-raw>.
Validation, Testing & Production Deployment
Production readiness requires automated validation, snapshot testing, and strict version control. MJML's deterministic output makes it ideal for CI-driven regression testing.
Jest Snapshot Testing:
const mjml2html = require('mjml');
const fs = require('fs');
test('order-confirmation.mjml compiles to stable HTML', () => {
const raw = fs.readFileSync('./templates/order-confirmation.mjml', 'utf8');
const { html } = mjml2html(raw, { minify: true });
expect(html).toMatchSnapshot();
});
Linting & Validation:
# Strict validation during compilation
npx mjml templates/transactional/*.mjml --output /dev/null --config.validationLevel=strict
# Check for missing alt attributes on images (manual review required)
grep -r 'alt=""' templates/ | grep 'mj-image'
Version Control Patterns:
- Store
.mjmlfiles in Git, not compiled.html. - Use
git diff --word-diffto track structural changes. - Tag releases with semantic versioning (e.g.,
email-templates@1.4.0). - Automate HTML diff checks in PR pipelines to catch unintended layout shifts.
Debugging Steps:
- Snapshot Failures: MJML compiler updates occasionally reorder attributes. Use
jest --updateSnapshotonly after verifying HTML parity in Litmus or Email on Acid. - Accessibility Gaps: MJML auto-generates
role="presentation"on tables. Manually addaria-labelvia<mj-text>wrappers for screen readers. - ESP Injection Conflicts: Some providers strip
<!DOCTYPE html>or inject tracking scripts that break table alignment. Test compiled HTML in a raw ESP sandbox before production deployment.
The MJML Component Model in Depth
Every MJML tag is a class that implements one of two contracts: an ending tag (a leaf that renders content, like mj-text or mj-image) or a non-ending tag (a container that lays out children, like mj-section or mj-column). The compiler builds a tree, resolves attributes top-down through mj-attributes and mj-class, then asks each node to emit HTML bottom-up. Understanding this two-phase walk is what lets you reason about why a column suddenly stacks, or why a padding value silently loses to a parent.
The layout invariant is rigid and worth memorising, because most "MJML broke my design" tickets are violations of it:
mjml → mj-body → mj-section → mj-column → (mj-text | mj-image | mj-button | mj-table | mj-raw)
mj-section becomes a full-width row (<table role="presentation" width="100%">). mj-column becomes a percentage-width cell that, on screens narrower than the mj-body width (default 600px), drops to 100% via a media query. Put an mj-text directly inside an mj-section and the compiler wraps it in an implicit column — but put an mj-section inside an mj-column and you get malformed nesting that renders inconsistently across Outlook and Apple Mail. The hierarchy is not a suggestion.
<mjml>
<mj-body width="600px"> <!-- desktop content width; below this columns stack -->
<mj-section padding="0">
<!-- two 50% columns on desktop, stacked 100% on iOS Mail / Gmail app -->
<mj-column width="50%">
<mj-image src="https://cdn.example.com/p.png" alt="Product" width="280px" />
</mj-column>
<mj-column width="50%">
<mj-text font-size="16px">Side-by-side on desktop</mj-text>
</mj-column>
</mj-section>
</mj-body>
</mjml>
A subtlety that bites teams: MJML columns do not stack in Outlook desktop. The Word rendering engine in Outlook 2016-2021 ignores the @media stacking query, so columns stay side-by-side at desktop widths there regardless of the viewport. This is intentional — Outlook desktop is always "desktop" — but it means you must design columns that look acceptable at full width, never relying on stacking for legibility in Outlook.
Custom Components: mjml-react and the CLI Plugin Path
There are two production routes to custom components, and they suit different teams. The first, shown earlier with registerComponent, extends the compiler itself — best when you want a true new tag (<mj-brand-footer>) usable by template authors who never touch JavaScript. The second is mjml-react, which lets you author the whole tree as JSX and render to an MJML string (or straight to HTML), which suits TypeScript backends that already think in components.
// mjml-react: author the tree in JSX, then compile. Renders identical table HTML to the XML path.
import { render } from '@faire/mjml-react/utils/render';
import { Mjml, MjmlBody, MjmlSection, MjmlColumn, MjmlText, MjmlButton } from '@faire/mjml-react';
function OrderConfirmation({ name, ctaUrl }) {
return (
<Mjml>
<MjmlBody width={600}>
<MjmlSection paddingBottom="0">
<MjmlColumn>
{/* Gmail clips at 102KB: keep the JSX lean, no inline data URIs */}
<MjmlText fontSize={16}>Thanks for your order, {name}.</MjmlText>
{/* mjml-button compiles to an Outlook-safe VML-backed button on Outlook 2016-2021 */}
<MjmlButton href={ctaUrl} backgroundColor="#A53860">View receipt</MjmlButton>
</MjmlColumn>
</MjmlSection>
</MjmlBody>
</Mjml>
);
}
const { html, errors } = render(<OrderConfirmation name="Sam" ctaUrl="https://app.example.com/r/1" />, {
validationLevel: 'soft', // 'strict' throws on unknown attrs; 'soft' collects them in errors[]
});
For the CLI plugin path, register custom components in a .mjmlconfig file at the project root so both the mjml binary and mjml2html discover them without per-call wiring:
{
"packages": [
"./components/MjBrandFooter.js",
"@example/mjml-loyalty-badge"
]
}
Once registered, npx mjml templates/order.mjml -o dist/order.html resolves <mj-brand-footer> exactly as it resolves built-ins. If you split work across the team, React Email Development is the cleaner home for component logic that's already TypeScript-native, while the CLI plugin path keeps authoring in plain .mjml for non-engineers.
Reusable Partials with mj-include and Fragment Types
mj-include has three resolution modes, and choosing the wrong one is a common source of "my styles vanished" reports. The default mode inlines an MJML fragment as if pasted in place. type="css" inlines a stylesheet into the surrounding mj-style. type="html" injects raw HTML untouched by the compiler.
<mj-head>
<!-- type="css": merged into <mj-style>, then auto-inlined for Gmail/Outlook which ignore <style> in <head> -->
<mj-include path="./partials/tokens.css" type="css" />
</mj-head>
<mj-body>
<!-- default type: fragment must contain valid MJML body components, not a full <mjml> doc -->
<mj-include path="./partials/header.mjml" />
<mj-section>
<mj-column><mj-text>Body</mj-text></mj-column>
</mj-section>
<mj-include path="./partials/footer.mjml" />
</mj-body>
The single most frequent failure here: a partial that wraps its content in <mjml><mj-body>…. An included fragment must contain only the components for the position it lands in — a header partial holds mj-sections, not a document root. The second failure is path resolution: mj-include resolves relative to the file being compiled, so when you call mjml2html programmatically you must pass filePath or every include throws EISDIR/ENOENT. Treat your partial directory as a strict acyclic graph; a header that includes a footer that includes the header trips Circular dependency detected and aborts the build.
Data Binding: Compile Order Is Non-Negotiable
MJML has no loops or conditionals. Dynamic data is the job of a templating engine that runs before the MJML compiler, never after — compiling first then string-replacing into table HTML reliably corrupts MSO conditional comments and breaks Outlook. The canonical order is: render template engine → feed result to mjml2html → ship HTML.
// Correct: Jinja2/Liquid/Handlebars renders MJML source, THEN mjml2html compiles it.
const nunjucks = require('nunjucks');
const mjml2html = require('mjml');
function buildEmail(templatePath, data) {
const mjmlSource = nunjucks.render(templatePath, data); // step 1: interpolate {{ }} and {% %}
const { html, errors } = mjml2html(mjmlSource, { // step 2: compile to table HTML
filePath: templatePath, // needed so mj-include resolves
validationLevel: 'strict',
});
if (errors.length) throw new Error(errors.map(e => e.formattedMessage).join('\n'));
return html; // step 3: hand to SES/Postmark/SendGrid as the body
}
When a loop must emit repeated MJML components, keep the loop tags on their own lines so the engine never splits an opening tag from its closing tag. If you wrap control flow inside mj-raw, the compiler passes it through verbatim — useful for {% if %} guards around whole sections. For Shopify-flavoured merge syntax, the dynamic-tag patterns in Liquid for Shopify Emails keep the rendered MJML balanced before it ever reaches the parser, and Python shops can lean on Jinja2 for Python apps for the same pre-compile step.
How mjml2html Output Maps to Table HTML
Reading compiler output demystifies the abstraction. Each MJML node has a predictable HTML signature, and knowing the mapping lets you debug rendering by inspecting dist/ instead of guessing.
| MJML node | Emitted HTML | Why it's shaped this way |
|---|---|---|
mj-body width="600px" |
centering <div> + outer <table> |
Apple Mail/Gmail centre the 600px content column |
mj-section |
<table role="presentation" width="100%"> + MSO <td> wrapper |
Outlook 2016-2021 Word engine needs an explicit table row |
mj-column width="50%" |
<div class="mj-column-per-50"> with a media query |
iOS Mail / Gmail app stack to 100%; Outlook stays 50% |
mj-image |
<img> inside a <td> with width/height set |
Gmail and Outlook drop unsized images, causing reflow |
mj-button |
nested table + <!--[if mso]> VML roundrect |
Outlook 2016-2021 ignores border-radius on anchors |
mj-style |
<style> in <head> and inlined attributes |
Gmail strips <head> styles, so MJML inlines them too |
The <!--[if mso]> blocks MJML emits around sections and buttons are exactly the conditional comments you would hand-write for Outlook rendering fixes — the compiler is generating, deterministically, the fallbacks most teams maintain by hand.
Provider and Client Constraint Reference
| Concern | Gmail (web/app) | Outlook 2016/2019/365 (Win) | Apple Mail / iOS Mail | Samsung Email | Sending (SES/SendGrid/Postmark/Mailgun) |
|---|---|---|---|---|---|
| Column stacking | Stacks via media query | Does not stack (Word engine) | Stacks via media query | Stacks via media query | n/a |
<head> <style> |
Stripped in Gmail app | Partially honored | Honored | Mostly honored | n/a |
| Payload limit | Clips message > 102KB | No hard limit | No hard limit | No hard limit | SES 10MB raw; SendGrid 30MB; Postmark 10MB |
| Background images | Honored | Needs VML (MJML injects) | Honored | Honored | n/a |
| Web fonts | Falls back to Arial | Falls back to Times/Arial | Honored | Mostly honored | n/a |
| Rounded buttons | CSS honored | Needs VML roundrect (MJML injects) | CSS honored | CSS honored | n/a |
Keep the compiled artifact under 102KB regardless of provider limits, because Gmail clips the displayed message and hides your unsubscribe footer behind a "[View entire message]" link — a deliverability and compliance risk independent of any ESP size cap.
Numbered Pipeline-Integration Steps
- Author
.mjmlsource and partials in the repo; never commit compiled.html. - Render data with the templating engine first, producing interpolated MJML in memory.
- Compile with
mjml2html({ validationLevel: 'strict', filePath })and fail the build on anyerrors[]. - Inline and strip — MJML inlines CSS during compile; run
email-combto remove unused classes. - Snapshot the compiled HTML in Jest so structural drift surfaces in PR review (see Jest snapshot testing for MJML templates).
- QA render the artifact in Litmus / Email on Acid workflows for Outlook and Gmail.
- Dispatch the finished HTML to the ESP — compilation must already be complete, never inline in the send path.
Named-Symptom Debugging
- Symptom: columns render side-by-side on a phone in the Gmail app. Cause: the section exceeds
mj-body width, or a fixedwidthin px onmj-columnoverrides the percentage. Fix: use percentage widths on columns and letmj-body widthdefine the breakpoint. - Symptom:
Circular dependency detectedon build. Cause: two partialsmj-includeeach other. Fix: refactor shared markup into a third leaf partial both include. - Symptom: dynamic
{% if %}block vanishes from output. Cause: control-flow tags weren't insidemj-raw, so the XML parser dropped them. Fix: wrap engine syntax in<mj-raw>or render with the template engine before compiling. - Symptom: button corners square only in Outlook 2016-2021. Cause: a hand-written anchor bypassed
mj-button, so no VML roundrect was emitted. Fix: usemj-button, or hand-author the VML per the bulletproof email buttons guide. - Symptom:
ENOENT/EISDIRonmj-includewhen compiling in Node. Cause:filePathnot passed tomjml2html. Fix: pass the absolute template path so includes resolve. - Symptom: Gmail shows "[View entire message]". Cause: compiled HTML exceeds 102KB. Fix: minify, run
email-comb, and remove unusedmj-styleblocks.
Validation Checklist
- Every
mj-columnuses a percentage width; no px widths force non-stacking on mobile mjml2htmlcalled withvalidationLevel: 'strict'and the build fails onerrors[]filePathpassed for any template usingmj-include- Partials contain only position-appropriate components, never a full
<mjml>root - Templating engine runs before the compiler; no string replacement on compiled HTML
- Compiled artifact verified ≤ 102KB after
email-comb - Every
mj-imagehas explicitwidth,height, andalt - Rendered in Outlook 2016-2021 and the Gmail app, not just a desktop browser preview
.mjmlsource committed; compiled.htmlexcluded from version control
Frequently Asked Questions
Does MJML produce smaller HTML than hand-coded tables?
Usually not — MJML emits verbose, defensive table markup. The win is determinism and maintainability, not byte count. Run email-comb and minification to claw back size before the 102KB Gmail clip threshold.
Can I use MJML and React Email in the same codebase?
Yes. Many teams render marketing layouts with MJML and product/transactional notifications with React Email. The trade-offs — compile determinism, payload, and team ergonomics — are compared in MJML vs React Email for transactional systems.
Why do my columns refuse to stack in Outlook?
By design. The Outlook 2016-2021 Word engine ignores the stacking media query, so columns stay side-by-side. Design columns that read acceptably at full desktop width.
Should I commit compiled HTML?
No. Commit .mjml source and compile in CI. Committing HTML produces noisy diffs and risks the source and artifact drifting apart.
Related
- MJML vs React Email for transactional systems — decide which compiler fits your notification stack
- Converting HTML emails to MJML components — migrate legacy table markup into the component model
- React Email Development — the JSX-based alternative for type-safe templates
- Liquid for Shopify Emails — pair dynamic data binding with the MJML compile step
← Back to Modern Email Templating Engines