Skip to content
EditorStackPlayground

Build an email template editor with GrapesJS and MJML

Set up grapesjs-mjml 1.0.8 on GrapesJS 0.23.5: MJML blocks in the editor, MJML source for storage, compiled table-based HTML for sending, and validation errors you can show. Tested in Chromium, including merge tags and invalid MJML.

Updated · Tested with GrapesJS 0.23.5

TL;DR

  • grapesjs-mjml 1.0.8 (BSD-3-Clause, published March 2026) declares grapesjs ^0.21.2 as a development dependency, but it loaded and compiled correctly on 0.23.5 in our test.
  • With the plugin active, editor.getHtml() returns MJML, which is what you store; the mjml-code-to-html command compiles it to a full HTML email with tables and inline styles, which is what you send.
  • Invalid MJML does not throw: the command returns HTML plus an errors array, so surface the errors before anyone sends the result.

Why MJML

Email HTML is its own dialect: nested tables, inline styles, workarounds for clients that render with old engines. MJML is a markup language that compiles to that dialect, and grapesjs-mjml puts MJML components (mj-section, mj-column, mj-text, mj-button and others) into the GrapesJS canvas, rendering them live with MJML's browser compiler.

Before building, read the email template builder use case. The editor is the smaller part of an email product; client rendering and deliverability are the larger ones, and that page prices the commercial alternatives.

Install

npm install grapesjs@0.23.5 grapesjs-mjml@1.0.8

grapesjs-mjml depends on mjml-browser (4.18.0 was resolved in our install), which is a substantial addition to the editor bundle. Load the email editor on its own route.

The editor

email-editor.ts
import grapesjs, { usePlugin, type Editor } from 'grapesjs';
import mjmlPlugin from 'grapesjs-mjml';
import 'grapesjs/dist/css/grapes.min.css';

const STARTER_TEMPLATE = `<mjml>
  <mj-body background-color="#f4f4f5">
    <mj-section background-color="#ffffff" padding="24px">
      <mj-column>
        <mj-text font-size="24px" font-weight="700" color="#111827">Your order has shipped</mj-text>
        <mj-text color="#374151">Hi {{first_name}}, your package is on its way.</mj-text>
        <mj-button background-color="#1f6feb" href="https://example.com/track">Track package</mj-button>
      </mj-column>
    </mj-section>
  </mj-body>
</mjml>`;

export const editor: Editor = grapesjs.init({
  container: '#gjs',
  height: '100vh',
  storageManager: false,
  // usePlugin types the options and unwraps the UMD bundle's `default` export.
  plugins: [usePlugin(mjmlPlugin, { resetDevices: true })],
  projectData: { pages: [{ component: STARTER_TEMPLATE }] },
});

type MjmlResult = { html: string; errors: Array<{ formattedMessage?: string; message?: string }> };

/** MJML source (to store and re-edit) and compiled HTML (to send). */
export function exportEmail(target: Editor = editor) {
  const mjml = target.getHtml();
  const { html, errors } = target.runCommand('mjml-code-to-html') as MjmlResult;
  return { mjml, html, errors: errors.map((e) => e.formattedMessage ?? e.message ?? String(e)) };
}

// Exposed for the test harness.
Object.assign(window, { editor, exportEmail });

Two details:

  • usePlugin. The plugin ships as a UMD bundle. Passing it through GrapesJS's usePlugin helper, which the GrapesJS plugin documentation recommends for typed options, worked with esbuild; our first attempt called the default import directly as a function and failed with "is not a function", because of how the bundle exposes its export.
  • Two outputs. getHtml() returns MJML, the source of truth you store and reopen. The mjml-code-to-html command, registered by the plugin, compiles the current MJML and returns { html, errors }.

What our test checks

The example is bundled with esbuild and run in headless Chromium. The test asserts that:

  1. the plugin registers mj-* blocks (15 of them in 1.0.8, from mj-1-column to mj-raw);
  2. the MJML template renders in the canvas as tables;
  3. getHtml() returns a string starting with <mjml that contains mj-button;
  4. the compiled HTML starts with a doctype, contains no mj- tags, contains tables and has the button's background:#1f6feb as an inline style;
  5. {{first_name}} is unchanged in both the MJML and the HTML;
  6. valid MJML produces an empty errors array, while an mj-column placed directly in mj-body still returns HTML but with one validation error;
  7. the browser logs no errors.

What this guide does not cover

  • Email client testing. We checked the HTML's structure, not how Outlook, Gmail or Apple Mail render it.
  • Sending. Merge-tag substitution, plain-text parts and delivery belong to your email service.
  • Every MJML component. The test exercises sections, columns, text and buttons. Social, navbar, hero and raw blocks were registered but not individually checked.
  • Persistence. Store the MJML from getHtml(), or the project JSON, as shown in saving to a database; compile to HTML when sending.

If the build side of this is starting to look large, GrapesJS vs Unlayer compares it with a commercial embeddable email editor, and the email editor SDK category lists the rest.

Frequently asked questions

Is grapesjs-mjml compatible with GrapesJS 0.23?
It worked in our test on 0.23.5: the plugin registered its blocks, rendered MJML in the canvas and compiled valid HTML. Its package declares grapesjs ^0.21.2 only as a development dependency and has no peer dependency, so npm will not warn either way. We tested the paths in this guide, not every block and trait.
What about grapesjs-preset-newsletter?
It is the other official route to email in GrapesJS, building table-based HTML directly instead of MJML. Version 1.0.2 was last published in June 2023. We did not test it with 0.23.5.
Does the compiled HTML render correctly in Outlook and Gmail?
MJML's output is designed for email clients, but we did not test in any email client. Rendering across clients is exactly what commercial email SDKs sell, and our email template builder use case covers that trade.
Do merge tags like {{first_name}} survive?
In our test, {{first_name}} inside mj-text came through both getHtml() and the compiled HTML unchanged. Tags inside attributes, such as href, were not tested.

Sources

  1. npm registry metadata for grapesjs-mjml (1.0.8, BSD-3-Clause) — accessed 2026-09-17
  2. GrapesJS MJML plugin repository and README — accessed 2026-09-17
  3. MJML documentation — accessed 2026-09-17
  4. GrapesJS documentation: Plugins (usePlugin) — accessed 2026-09-17
  5. npm registry metadata for grapesjs-preset-newsletter — accessed 2026-09-17

Written by the EditorStack Research Team. How we test and what we refuse to publish is on the methodology page.