Skip to main content

MJML Component Architecture: Implementation Patterns & Rendering Workflows

Design scalable MJML component systems—section patterns, responsive grids, and cross-client rendering workflow best practices.

MJML compile flow A semantic MJML component tree passes through the compiler and emits nested table HTML with inlined CSS and Outlook VML fallbacks. MJML Tree to Responsive HTML Component Tree <mjml> <mj-body> <mj-section> <mj-column> <mj-text> <mj-image> <mj-section> </mjml> mjml2html compiler core Responsive HTML nested <table> layout inlined CSS attributes VML for Outlook 2016-2021 media queries for iOS Mail role=presentation tables ≤ 102KB for Gmail
The MJML compiler walks a semantic component tree and emits table-driven HTML with inlined CSS and per-client fallbacks.

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:

  1. Run npx mjml template.mjml --config.minify false --config.beautify true to inspect compiler output with readable formatting.
  2. Add --config.validationLevel=strict to surface warnings about unknown attributes or tag placement.
  3. 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 filePath option when calling mjml2html programmatically.
  • 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-class to 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.all with bounded concurrency rather than sequential calls.
  • Async Data Injection: Never compile MJML after ESP API calls. Pre-render HTML, then attach to SendGrid, Postmark, or AWS SES payloads.
  • Config Drift: Pin mjml versions in package.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 provide font-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 .mjml files in Git, not compiled .html.
  • Use git diff --word-diff to 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 --updateSnapshot only after verifying HTML parity in Litmus or Email on Acid.
  • Accessibility Gaps: MJML auto-generates role="presentation" on tables. Manually add aria-label via <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

  1. Author .mjml source and partials in the repo; never commit compiled .html.
  2. Render data with the templating engine first, producing interpolated MJML in memory.
  3. Compile with mjml2html({ validationLevel: 'strict', filePath }) and fail the build on any errors[].
  4. Inline and strip — MJML inlines CSS during compile; run email-comb to remove unused classes.
  5. Snapshot the compiled HTML in Jest so structural drift surfaces in PR review (see Jest snapshot testing for MJML templates).
  6. QA render the artifact in Litmus / Email on Acid workflows for Outlook and Gmail.
  7. 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 fixed width in px on mj-column overrides the percentage. Fix: use percentage widths on columns and let mj-body width define the breakpoint.
  • Symptom: Circular dependency detected on build. Cause: two partials mj-include each 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 inside mj-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: use mj-button, or hand-author the VML per the bulletproof email buttons guide.
  • Symptom: ENOENT/EISDIR on mj-include when compiling in Node. Cause: filePath not passed to mjml2html. 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 unused mj-style blocks.

Validation Checklist

  • Every mj-column uses a percentage width; no px widths force non-stacking on mobile
  • mjml2html called with validationLevel: 'strict' and the build fails on errors[]
  • filePath passed for any template using mj-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-image has explicit width, height, and alt
  • Rendered in Outlook 2016-2021 and the Gmail app, not just a desktop browser preview
  • .mjml source committed; compiled .html excluded 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.


← Back to Modern Email Templating Engines