Skip to content

Migrating from MJML ​

This guide is for teams who've been authoring email templates in raw MJML (with editors like VS Code, an internal CLI, or a hand-rolled build pipeline) and want to move to Templatical's visual editor. @templatical/import-mjml converts an MJML document into Templatical's TemplateContent format directly — install it, run it, and use the sections below to finish off anything it can't map on its own.

Installation ​

bash
npm install @templatical/import-mjml
bash
pnpm add @templatical/import-mjml
bash
yarn add @templatical/import-mjml
bash
bun add @templatical/import-mjml

Without a build step (CDN) ​

You can also load it from a CDN:

html
<script type="module">
  import { convertMjmlTemplate } from 'https://cdn.jsdelivr.net/npm/@templatical/import-mjml/+esm';
  // ...then convert as shown in Usage below
</script>

Usage ​

ts
import { convertMjmlTemplate } from '@templatical/import-mjml';

// Load the raw MJML source of an email
const res = await fetch('/path/to/email.mjml');
const mjml = await res.text();

// Convert to Templatical format
const { content, report } = convertMjmlTemplate(mjml);

// Use in the editor
const editor = await init({
  container: '#editor',
  content,
});

// Check the conversion report for any issues
console.log(report);

Open in playground

convertMjmlTemplate is synchronous and returns an ImportResult with:

  • content — the converted TemplateContent ready for the editor
  • report — a conversion report with the status of each source element (converted, approximated, html-fallback, or skipped)

Reading the report ​

Each report.entries item describes one source element:

StatusMeaning
convertedEvery attribute with a Templatical home was carried across.
approximatedMapped to the right block, but a value was coerced to fit a closed range — note states the original value.
html-fallbackNo block equivalent exists; the original markup is preserved in an HtmlBlock.
skippedNothing was produced (templaticalBlockType: null).
ts
console.log(report.summary);
// { total: 24, converted: 21, approximated: 2, htmlFallback: 1, skipped: 0 }

for (const entry of report.entries) {
  if (entry.status === 'approximated') {
    console.warn(`<${entry.sourceTag}> approximated:`, entry.note);
  }
}
// <mj-section> approximated: Column widths 40%, 60% have no exact Templatical layout; resolved to "1-2".

A note on an approximated entry always names the value it replaced, so diffing report.entries between two runs shows exactly what a migration changed.

Conversion model ​

This is a slightly counter-intuitive migration. Templatical's renderer produces MJML output — so on the surface, MJML and Templatical look identical. But:

  • MJML is a markup language. You write XML-like tags (<mj-section>, <mj-column>, <mj-text>) and the MJML compiler turns them into table-based HTML.
  • Templatical stores templates as a JSON tree of typed blocks (SectionBlock, ParagraphBlock, etc.) and renders that tree to MJML at export time.

To bring an MJML template into Templatical, you parse the MJML and construct an equivalent JSON tree — including resolving MJML's own attribute-inheritance rules (mj-all, per-tag defaults, mj-class) before mapping each element. @templatical/import-mjml does exactly that; the mapping table below is what it implements.

Path 1 — Rebuild visually using your MJML as a reference ​

For a handful of templates, rebuilding by hand next to your MJML source is often faster than installing a package:

  1. Open your MJML source in your editor of choice.
  2. Open the Templatical editor (or the playground) side-by-side.
  3. Compile your MJML to HTML once and preview it — that's your visual target.
  4. Drag in the equivalent Templatical blocks (see the mapping table below).
  5. Copy text content directly. Re-host images via your media library.
  6. Reproduce styling using Templatical's design tokens instead of inline mj-attributes.

Most MJML templates port in 10–20 minutes once you've done one or two. For a larger batch, run @templatical/import-mjml first and use this path only to finish off anything it left as an HTML-fallback block.

Path 2 — Use Templatical's renderer to verify your work ​

Once you have a template in Templatical — imported or rebuilt by hand:

ts
import { renderToMjml } from '@templatical/renderer';

const mjml = await renderToMjml(content);
// Compare this MJML against your original MJML source.

Run a diff between the original and Templatical-generated MJML to spot structural differences. This is a useful sanity check before bulk-migrating.

MJML tag mapping ​

MJML tagTemplatical blockNotes
mj-section (containing mj-columns)SectionBlock with columnsMulti-column layouts work the same way; column widths come from MJML's width attribute or are equally distributed. border-radius and border too (see Borders and corners). background-url with its size, position and repeat becomes backgroundImage (see Background images).
mj-columnSection columnA column holds a list of nested blocks.
mj-groupSectionBlock.stackOnMobile: falseNot a block at all — marks the section's columns to stay side by side on mobile instead of stacking.
mj-textTitleBlock / TableBlock / MenuBlock / ParagraphBlockResolved structurally: a single heading root becomes TitleBlock, a single <table> becomes TableBlock, span-separated top-level anchors with no paragraph wrapper and no non-whitespace text-node siblings become MenuBlock, a sentence of copy plus a trailing <a> becomes ParagraphBlock, anything else becomes ParagraphBlock. A paragraph's align, color and font-size go into its markup: text-align on each <p>, color and size on one <span> inside it (ParagraphBlock has no field for them). Alignment to the start edge (left, or right in an RTL template) and a 14px size are left out.
mj-imageImageBlocksrc, alt, href, width (see Image width), padding, border-radius, border (see Borders and corners). fluid-on-mobile holds on an image that fills its column; on a narrower one it is approximated, since the image keeps its width on phones.
mj-buttonButtonBlockhref, background-color, color, font-size, font-family, padding, width (see Button width), border-radius, border (see Borders and corners).
mj-dividerDividerBlockborder-color, border-width, width (see Divider width), padding.
mj-spacerSpacerBlockheight.
mj-social (with mj-social-element)SocialIconsBlockEach mj-social-element → a SocialIcon entry. Platform comes from name, the src filename (pack suffixes such as -round-outlined stripped), or alt.
mj-navbar (with mj-navbar-link)MenuBlockEach link → MenuItemData.
mj-tableTableBlockMaps <tr>/<td>/<th> rows and cells to Templatical's table data; a leading <th> row sets hasHeaderRow.
mj-rawHtmlBlockInner markup preserved verbatim.
mj-wrapperSectionBlock.wrapperThe section's outer band, not a section of its own. One section inside folds into its wrapper; several share the same band, flagged approximated. background-color, background-url (with its size, position and repeat), padding and border-radius carry over; a border is dropped and reported.
mj-hero, mj-carousel, mj-accordionHtmlBlockConverted to an HTML block with the original markup preserved.
mjmlTemplate settingslang → locale; dir → direction, kept only when it differs from the direction the language implies.
mj-head contentTemplate settingsmj-preview → preheaderText; mj-attributes/mj-font/mj-style set the document's font, text color, and link color/underline. mj-title has no settings equivalent and is dropped with a warning.

Image width ​

A px width becomes "full" once it reaches the image's room: its column less its own side padding and border, which is where MJML caps it. A narrower px width stays px, and an image with no width is "full".

Divider width ​

mj-divider widthDividerBlock.widthStatus
missing, or 100%"full"converted
a percentage under 100%, such as 50%the same percentage to two decimals, "50%"converted
below 0% or above 100%clamped to "0%" or "full"approximated
px, narrower than the line can spanthe px numberconverted
px, as wide as the line can span or wider"full"converted
px, below 0clamped to 0approximated
any other value"full"approximated

The line can span its column less the column's side padding and the divider's own, which is how mj-divider draws 100%. A column's width is its share of mj-body's width, by the section's column layout.

Templatical centres every divider. A partial-width divider that MJML aligns left or right (align) is approximated, and its note names the alignment.

Button width ​

mj-button widthButtonBlock.widthStatus
missingnone, so the button fits its labelconverted
100%"full"converted
px, above 0the px numberconverted
a percentage under 100%, such as 50%the px it comes toapproximated
above 100%"full"approximated
px or a percentage that comes to 0px or lessnone, so the button fits its labelapproximated
any other valuenone, so the button fits its labelapproximated

A percentage is measured against the width the button can fill: its column less the column's side padding and the button's own, which is the box mj-button sizes it against. ButtonBlock.width holds no percentage, so the px it comes to no longer scales with the column, and the entry's note says so. A px width stays px however wide it is, because Templatical's renderer writes "full" as 100%.

Borders and corners ​

mj-section, mj-image and mj-button keep their border-radius and border; mj-wrapper keeps its border-radius.

border-radiusCorner radiusStatus
one to four px values, such as 8px or 8px 8px 0 0the same corners, one number when all four matchconverted
any other value, such as 50%, 1em or 8px / 4pxsquare cornersapproximated
border or border-<side>That sideStatus
a px width, solid, dashed or dotted, and a colourthe sameconverted
style none or hidden, no style, or a 0 widthnot drawnconverted
style double, groove, ridge, inset or outsetdrawn solidapproximated
a width that is not px, such as 2em or thin, or no widthnot drawnapproximated

A side's own border-<side> wins over border, as it does in MJML. A missing colour imports as unset, which draws in the inherited text colour (currentColor) as it did in MJML, and so does a colour the importer can't read. Templatical's wrapper has no border, so a border on mj-wrapper is dropped and its entry's note names it.

Background images ​

background-url on an mj-section becomes the section's backgroundImage, and on an mj-wrapper the band's wrapper.backgroundImage. background-size, background-position (and -x / -y) and background-repeat come along, and an attribute the element leaves out takes MJML's own default (auto, top center, repeat), so the image paints where it did.

A value with no exact Templatical keyword maps to the nearest one, and the entry is approximated with a note:

  • Size — a size that fills a side (100%, 100% auto, auto 100%, or a px width at least the section's) becomes cover; any other length or percentage becomes auto.
  • Position — a percentage snaps to the nearest of left, center and right (top, center and bottom); a length is an offset from the left or top edge and maps there.
  • Repeat — repeat-x, repeat-y, round and space become repeat.

mj-column has no background-url in MJML, so there is no column image to import. mj-hero still imports as an HTML block.

Where the mapping is lossy ​

MJML produced by Templatical's own renderer round-trips through the importer with no approximations to layout, style, or display conditions. The one gap is block type: a VideoBlock and an HtmlBlock both render as plain MJML with no marker of their origin, so each re-imports as a different kind of block (see below). Hand-written MJML converts cleanly for every tag in the mapping table above; anything the table doesn't cover lands as an HtmlBlock holding the original markup. Within what the table does cover, a few conversions are approximations rather than exact matches:

  • Column geometry — Templatical supports five column layouts (1, 2, 3, 2-1, 1-2). MJML allows any number of columns at any width, so a ratio outside those five resolves to the nearest layout, and a fourth or later column's content folds into the last column.
  • Social icon sizes — SocialIconsBlock supports three sizes (24px, 32px, 48px). An mj-social-element's icon-size outside those three resolves to the nearest one.
  • Heading levels — an <h5> or <h6> inside mj-text clamps to heading level 4, the highest a TitleBlock supports.
  • Divider width and alignment — an mj-divider width outside 0–100% or below 0px is clamped into range, an unreadable one becomes "full", and a partial-width divider aligned left or right is centred. See Divider width.
  • Button width — an mj-button width given as a percentage other than 100% becomes the px it comes to, one above 100% becomes "full", and one that is unreadable or comes to 0px or less leaves the button fitting its label. See Button width.
  • Borders and corners — a border-radius that isn't in px imports as square corners, a border style other than solid, dashed or dotted is drawn solid, a border side with no px width is left undrawn, and a border on mj-wrapper is dropped. See Borders and corners.
  • Background images — a background-size, background-position or background-repeat with no exact Templatical keyword maps to the nearest one. See Background images.
  • Video blocks — a VideoBlock renders the same way a linked ImageBlock does, so nothing in the MJML marks it as video. Importing that markup back produces an ImageBlock holding the same thumbnail and link; content survives, the block type does not.
  • HTML blocks — for the same reason, an HtmlBlock's content renders as plain mj-text markup with nothing marking it as HTML. Importing it back produces a ParagraphBlock holding the same markup.
  • Block IDs — every imported block gets a freshly generated ID. IDs never appear in rendered MJML, so nothing that keys off one — a Cloud comment thread, for example — survives a round trip.

Things that don't map automatically ​

  • mj-include — the importer reads a single MJML string with no filesystem access, so an unresolved <mj-include> is skipped with a warning naming its path attribute. Inline the included content before importing.
  • Custom MJML components — an unrecognized mj-* tag lands as an HtmlBlock holding its rendered markup automatically. Reimplement it as a Templatical custom block if you want it editable as a native block instead.

Further help ​

Open a discussion with a redacted snippet of your MJML and what you're trying to achieve. We use these reports to improve @templatical/import-mjml's coverage.