MJML email in one HTML file

MJML is a short markup language that compiles into the table-heavy HTML an email needs. Its browser build runs inside a plain HTML file, so you can write, check and download an email without installing anything.

MJML is a markup language from Mailjet for coding responsive email. You write short tags such as <mj-section> and <mj-button>, and its open-source engine turns them into the HTML an email needs.

The mjml-browser package runs that engine in a web page, so a single HTML file is enough.

Here is a whole MJML email, compiled in the page you are looking at:

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MJML in one HTML file</title>
<style>
  body { margin: 0; padding: 14px; font-family: system-ui, sans-serif; background: #eef0f3; color: #1d2330; }
  #status { margin: 0 0 10px; font-size: 14px; color: #4b5563; }
  /* the compiled email is drawn inside this box */
  #preview { background: #fff; border-radius: 10px; overflow: hidden; }
</style>
</head>
<body>
<p id="status">Compiling...</p>
<div id="preview"></div>

<!-- 1. the email, written in MJML. type="text/plain" keeps the browser from running or parsing it -->
<script type="text/plain" id="email">
<mjml>
  <mj-body background-color="#f4f4f7">
    <mj-section background-color="#ffffff">
      <mj-column>
        <mj-text font-size="22px" font-weight="bold">Your order has shipped</mj-text>
        <mj-text>Hi Sam, parcel #4821 left our warehouse today and should arrive on Friday.</mj-text>
        <mj-button background-color="#2563eb" href="#">Track parcel</mj-button>
      </mj-column>
    </mj-section>
  </mj-body>
</mjml>
</script>

<!-- 2. the MJML compiler for browsers (pinned version) -->
<script src="https://cdn.jsdelivr.net/npm/mjml-browser@5.4.1/lib/index.js"></script>

<!-- 3. compile, then show the HTML it made -->
<script>
  const source = document.getElementById('email').textContent;

  mjml(source).then((result) => {
    // result.html is a full email HTML document: tables and inline styles
    const box = document.getElementById('preview').attachShadow({ mode: 'open' });
    box.innerHTML = result.html;  // shadow root keeps the email's CSS away from this page
    document.getElementById('status').textContent =
      'MJML ' + source.length + ' characters  ->  HTML ' + result.html.length + ' characters, ' +
      result.errors.length + ' errors';
  });
</script>
</body>
</html>
Eleven lines of MJML become a full email document. Change the text or a colour and it compiles again.

The page holds three things: the MJML, one script tag for the compiler, and a few lines that call it and show the result.

Responsive email HTML usually means nested tables and CSS written for particular email clients. MJML writes that part for you. The 11 lines above become a document of more than 5,000 characters.

You write MJML, the engine compiles it, and you send the HTML it produces.
You write MJML, the engine compiles it, and you send the HTML it produces.

The output is a complete document starting with <!doctype html>. It contains tables, inline styles and @media rules, and no script. That HTML, not the MJML, is what goes into your email tool. Send raw HTML email covers that last step.

The smallest working HTML file

Four pieces, in this order:

  1. Write the email in MJML inside <script type="text/plain">. The browser treats a script with an unknown type as a block of data, so it neither runs it nor turns the tags into elements.
  2. Load the browser compiler with a pinned version. The file defines a global function called mjml.
  3. Compile and wait. mjml(source) returns a Promise. The result has html, json and an errors list.
  4. Show or save the HTML. The demo puts it into a shadow root, so the email's CSS cannot restyle the rest of the page.
<script type="text/plain" id="email">
<mjml>
  <mj-body>
    <mj-section>
      <mj-column>
        <mj-text>Hello</mj-text>
      </mj-column>
    </mj-section>
  </mj-body>
</mjml>
</script>

<script src="https://cdn.jsdelivr.net/npm/mjml-browser@5.4.1/lib/index.js"></script>
<script>
  const source = document.getElementById('email').textContent;
  mjml(source).then((result) => {
    document.getElementById('preview')
      .attachShadow({ mode: 'open' }).innerHTML = result.html;
  });
</script>

Version 5.4.1 was the latest on npm on 1 October 2026. Pinning it means a new release cannot change your page without you knowing.

The one rule: section, then column, then content

An MJML document holds only mj-head and mj-body. Inside the body, every mj-section needs at least one mj-column, and text, images and buttons go inside the column. A column cannot hold another column or a section.

Content placed straight in a section is reported as an error. Columns are what make the email stack on narrow screens.
Content placed straight in a section is reported as an error. Columns are what make the email stack on narrow screens.

The email is 600px wide by default, set by width on mj-body. Columns in a section share that width evenly unless you give them a width.

On a narrow screen the columns stack. In the compiled output, the switch happens at 480px unless you set <mj-breakpoint> in the head.

Tag Goes inside What it is for
mj-head mjml Title, preview text, default attributes, styles
mj-body mjml The email itself, 600px wide by default
mj-section mj-body One horizontal band of the email
mj-column mj-section A column; stacks on narrow screens
mj-text, mj-button, mj-image mj-column The content

mj-text and mj-button are "ending tags". They accept ordinary HTML such as <b> and <br>, but no other MJML tags. The MJML docs advise writing &lt; and &gt; for literal angle brackets in them.

Version 5 compiles asynchronously

Code written for version 4, like the example in the version 4 README, calls the compiler and reads the result on the next line.

In version 5, the README awaits it instead. Copy the old pattern and result.html is undefined. No error appears; the preview just shows the word "undefined".

The same two lines, with and without await.
The same two lines, with and without await.

Use await inside an async function, or .then() as in the example above. Async and await in JavaScript explains the pattern if it is new.

Catching mistakes: soft and strict validation

MJML checks your tags before it builds the email. The default level, soft, builds the email anyway and lists the problems in result.errors. strict refuses to build and throws. skip does not check at all.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MJML errors, soft and strict</title>
<style>
  body { margin: 0; padding: 14px; font-family: system-ui, sans-serif; background: #eef0f3; color: #1d2330; }
  .bar { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-bottom: 8px; font-size: 14px; }
  button, select { padding: 6px 10px; font: inherit; border: 1px solid #c9ced6; border-radius: 6px; background: #fff; color: #1d2330; cursor: pointer; }
  textarea { box-sizing: border-box; width: 100%; height: 215px; padding: 8px; font: 13px/1.45 ui-monospace, Consolas, monospace; border: 1px solid #c9ced6; border-radius: 8px; resize: vertical; }
  #errors { margin: 8px 0; padding: 8px 10px; border-radius: 8px; font-size: 13px; line-height: 1.45; }
  #errors.ok { background: #e3f5e9; color: #0f5132; }
  #errors.bad { background: #fde2da; color: #9a3412; }
  #errors ul { margin: 4px 0 0; padding-left: 18px; }
  #preview { background: #fff; border-radius: 10px; overflow: hidden; min-height: 40px; }
</style>
</head>
<body>
<div class="bar">
  <button id="broken">Broken example</button>
  <button id="fixed">Fixed example</button>
  <label>Validation <select id="level">
    <option value="soft">soft (default)</option>
    <option value="strict">strict</option>
  </select></label>
</div>
<textarea id="src" spellcheck="false" aria-label="MJML source"></textarea>
<div id="errors"></div>
<div id="preview"></div>

<script src="https://cdn.jsdelivr.net/npm/mjml-browser@5.4.1/lib/index.js"></script>
<script>
  // mj-text sits straight inside mj-section: not allowed, it needs an mj-column
  const BROKEN = `<mjml>
  <mj-body>
    <mj-section background-color="#ffffff">
      <mj-text font-size="20px">Spring sale</mj-text>
      <mj-column>
        <mj-text>Everything 20% off until Sunday.</mj-text>
      </mj-column>
    </mj-section>
  </mj-body>
</mjml>`;
  const FIXED = BROKEN
    .replace('      <mj-text font-size="20px">Spring sale</mj-text>\n      <mj-column>\n',
             '      <mj-column>\n        <mj-text font-size="20px">Spring sale</mj-text>\n');

  const src = document.getElementById('src');
  const level = document.getElementById('level');
  const errors = document.getElementById('errors');
  const preview = document.getElementById('preview').attachShadow({ mode: 'open' });

  function list(messages) {
    return '<ul>' + messages.map((m) => '<li>' + m.replace(/</g, '&lt;') + '</li>').join('') + '</ul>';
  }

  async function compile() {
    try {
      const result = await mjml(src.value, { validationLevel: level.value });
      preview.innerHTML = result.html;
      if (result.errors.length) {
        // soft: the email is still built, the problems come back in result.errors
        errors.className = 'bad';
        errors.innerHTML = 'Rendered anyway, with ' + result.errors.length + ' problem(s):' +
          list(result.errors.map((e) => e.formattedMessage));
      } else {
        errors.className = 'ok';
        errors.textContent = 'No errors. The email below is the compiled HTML.';
      }
    } catch (err) {
      // strict, or input that is not MJML at all: nothing is rendered
      preview.innerHTML = '';
      errors.className = 'bad';
      errors.innerHTML = 'Not rendered:' + list([err.message]);
    }
  }

  let timer;
  src.addEventListener('input', () => { clearTimeout(timer); timer = setTimeout(compile, 300); });
  level.addEventListener('change', compile);
  document.getElementById('broken').addEventListener('click', () => { src.value = BROKEN; compile(); });
  document.getElementById('fixed').addEventListener('click', () => { src.value = FIXED; compile(); });

  src.value = BROKEN;
  compile();
</script>
</body>
</html>
The broken example puts mj-text straight inside mj-section. Switch to strict, then load the fixed version.

Under soft, a misplaced tag still renders, often in the wrong place, so check result.errors.length before you send anything. Use strict when a bad template should stop the job. Under the default level, input without an <mjml> root fails with "Malformed MJML".

try {
  const result = await mjml(source, { validationLevel: 'strict' });
  save(result.html);
} catch (err) {
  console.log(err.message);  // which line, which tag, what is wrong
}

A finished newsletter you can download

This one adds the parts a real email uses: mj-title, mj-preview for the inbox preview text, and mj-attributes so every tag shares one font without repeating it.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MJML newsletter</title>
<style>
  body { margin: 0; padding: 14px; font-family: system-ui, sans-serif; background: #eef0f3; color: #1d2330; }
  .bar { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-bottom: 10px; font-size: 14px; }
  button { padding: 7px 12px; font: inherit; border: 0; border-radius: 6px; background: #2563eb; color: #fff; cursor: pointer; }
  #info { color: #4b5563; }
  details { margin-bottom: 10px; font-size: 14px; }
  textarea { box-sizing: border-box; width: 100%; height: 160px; margin-top: 6px; font: 12px/1.4 ui-monospace, Consolas, monospace; }
  #preview { background: #fff; border-radius: 10px; overflow: hidden; }
</style>
</head>
<body>
<div class="bar">
  <button id="download">Download email.html</button>
  <span id="info">Compiling...</span>
</div>
<details>
  <summary>Show the compiled HTML</summary>
  <textarea id="out" readonly aria-label="Compiled HTML"></textarea>
</details>
<div id="preview"></div>

<script type="text/plain" id="email">
<mjml>
  <mj-head>
    <mj-title>Garden Club - May</mj-title>
    <mj-preview>Seed swap on Saturday, plus two new beds</mj-preview>
    <mj-attributes>
      <!-- defaults for every component, so each tag stays short -->
      <mj-all font-family="Helvetica, Arial, sans-serif" />
      <mj-text font-size="15px" line-height="1.5" color="#333333" />
    </mj-attributes>
  </mj-head>
  <mj-body background-color="#eef2ee">
    <mj-section background-color="#2f6b3a">
      <mj-column>
        <mj-text align="center" color="#ffffff" font-size="24px" font-weight="bold">Garden Club</mj-text>
        <mj-text align="center" color="#d7ead9" padding-top="0">May newsletter</mj-text>
      </mj-column>
    </mj-section>
    <mj-section background-color="#ffffff">
      <mj-column>
        <mj-text font-size="20px" font-weight="bold">Seed swap this Saturday</mj-text>
        <mj-text>Bring spare seeds in labelled envelopes. Tables open at 10:00 by the shed.</mj-text>
        <mj-button background-color="#2f6b3a" href="#">Add to calendar</mj-button>
      </mj-column>
    </mj-section>
    <!-- two columns side by side; on a narrow screen they stack -->
    <mj-section background-color="#ffffff">
      <mj-column>
        <mj-image width="120px" alt="Tomato" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 120 120'%3E%3Ccircle cx='60' cy='66' r='44' fill='%23e04a3a'/%3E%3Cpath d='M44 22h32l-16 18z' fill='%232f6b3a'/%3E%3C/svg%3E" />
        <mj-text align="center"><b>Bed 4: tomatoes</b><br>Planted by the Lee family.</mj-text>
      </mj-column>
      <mj-column>
        <mj-image width="120px" alt="Pea pod" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 120 120'%3E%3Crect x='14' y='44' width='92' height='34' rx='17' fill='%2365a844'/%3E%3Ccircle cx='38' cy='61' r='9' fill='%23a6d67f'/%3E%3Ccircle cx='60' cy='61' r='9' fill='%23a6d67f'/%3E%3Ccircle cx='82' cy='61' r='9' fill='%23a6d67f'/%3E%3C/svg%3E" />
        <mj-text align="center"><b>Bed 5: peas</b><br>Free to anyone who waters it.</mj-text>
      </mj-column>
    </mj-section>
    <mj-section>
      <mj-column>
        <mj-divider border-color="#c8d6c8" border-width="1px" />
        <mj-text align="center" font-size="12px" color="#6b7280">You get this because you joined the Garden Club. Reply STOP to leave.</mj-text>
      </mj-column>
    </mj-section>
  </mj-body>
</mjml>
</script>

<script src="https://cdn.jsdelivr.net/npm/mjml-browser@5.4.1/lib/index.js"></script>
<script>
  const source = document.getElementById('email').textContent;
  let html = '';

  mjml(source).then((result) => {
    html = result.html;
    document.getElementById('preview').attachShadow({ mode: 'open' }).innerHTML = html;
    document.getElementById('out').value = html;
    document.getElementById('info').textContent =
      (html.length / 1024).toFixed(1) + ' KB of email HTML, ' + result.errors.length + ' errors';
  });

  // save the compiled HTML as a file, ready to paste into an email tool
  document.getElementById('download').addEventListener('click', () => {
    const link = document.createElement('a');
    link.href = URL.createObjectURL(new Blob([html], { type: 'text/html' }));
    link.download = 'email.html';
    link.click();
    setTimeout(() => URL.revokeObjectURL(link.href), 1000);
  });
</script>
</body>
</html>
Two columns side by side that stack on a phone, preview text in the head, and a button that saves the compiled HTML as email.html.
  • Defaults in one place: <mj-all font-family="..."> inside mj-attributes applies to every component.
  • Images: the demo draws its pictures as data: SVGs so the page stays self-contained. In the email you send, point src at images hosted on your own server, as the examples in the MJML docs do.
  • Saving: the button wraps result.html in a Blob and clicks a link with a download attribute.

Before you send, look at the result in more than one inbox. Preview HTML email in multiple clients lists ways to do that.

The same email with the command line

For a project, the MJML README installs the npm package and compiles files on disk. The command line tool can also process mj-include once you turn on its allowIncludes setting. The browser build always ignores it.

npm install mjml
npx mjml input.mjml -o output.html
import mjml2html from 'mjml';
const { html, errors } = await mjml2html(source, { validationLevel: 'strict' });

That code runs in Node.js, not in a plain HTML file. The MJML you write is the same either way, so a template tested in the browser moves across unchanged.

When it does not work

What you see Cause Fix
mjml is not defined Your script runs before the compiler's script tag Put the mjml-browser tag above your code
The preview shows "undefined" result.html read without waiting await mjml(source) or .then()
"mj-text cannot be used inside mj-section" Content placed straight in a section Wrap it in mj-column
Tags end up inside the tag before them MJML placed in a normal div, so the HTML parser ignores /> Keep MJML in script type="text/plain" or a JS string
"Malformed MJML" No <mjml> root around the email Start with <mjml> and <mj-body>
An mj-include part is missing The browser build ignores mj-include Paste the part in, or use the CLI with includes on
Your page's fonts and colours change The email's CSS was injected into the page Put result.html in a shadow root

The HTML parser only honours /> on void elements such as <br>. An MJML tag like <mj-divider /> inside a div stays open, and the next tag is nested inside it. The same email in a text block compiles cleanly.

An email template is easier to approve when people can see it render. Sending the compiled .html as an attachment may show code, or nothing, on a phone. Sending the MJML file shows only tags.

To send the working page, paste it into a NOS document and choose Create share link. HTML to link walks through it.

The page renders as written, its scripts run and the compiler loads from jsDelivr, so reviewers see the email and can download the HTML themselves. Change the MJML later and the same link shows the new version.

Questions people ask

Do I need Node.js or npm to use MJML?

Not to try it. The mjml-browser package runs the compiler in a web page, and a pinned copy loads from jsDelivr with one script tag. For a project, the MJML README installs the npm package and compiles files with the mjml command line tool.

Why is result.html undefined?

In MJML 5, the compile function returns a Promise, as the project README shows. Read result.html after await mjml(source), or inside .then(). Code copied from version 4 examples reads it straight away and gets undefined.

Can I send the .mjml file as the email?

No. MJML is the source you write. What goes into your email tool is the HTML the compiler produces, which is a full document with tables, inline styles and media queries.

Does mj-include work in the browser build?

No. The mjml-browser README says mj-include tags are unavailable and ignored, and features that need a .mjmlconfig file, such as custom components, are not available either. Keep the whole email in one MJML string, or compile with the command line tool.

Is MJML free?

Yes. The MJML repository and the npm packages are published under the MIT licence.

Keep reading