English
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-mjmlbash
pnpm add @templatical/import-mjmlbash
yarn add @templatical/import-mjmlbash
bun add @templatical/import-mjmlWithout 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);convertMjmlTemplate is synchronous and returns an ImportResult with:
content— the convertedTemplateContentready for the editorreport— a conversion report with the status of each source element (converted,approximated,html-fallback, orskipped)
Reading the report
Each report.entries item describes one source element:
| Status | Meaning |
|---|---|
converted | Every attribute with a Templatical home was carried across. |
approximated | Mapped to the right block, but a value was coerced to fit a closed range — note states the original value. |
html-fallback | No block equivalent exists; the original markup is preserved in an HtmlBlock. |
skipped | Nothing 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:
- Open your MJML source in your editor of choice.
- Open the Templatical editor (or the playground) side-by-side.
- Compile your MJML to HTML once and preview it — that's your visual target.
- Drag in the equivalent Templatical blocks (see the mapping table below).
- Copy text content directly. Re-host images via your media library.
- 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 tag | Templatical block | Notes |
|---|---|---|
mj-section (containing mj-columns) | SectionBlock with columns | Multi-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-column | Section column | A column holds a list of nested blocks. |
mj-group | SectionBlock.stackOnMobile: false | Not a block at all — marks the section's columns to stay side by side on mobile instead of stacking. |
mj-text | TitleBlock / TableBlock / MenuBlock / ParagraphBlock | Resolved 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-image | ImageBlock | src, 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-button | ButtonBlock | href, background-color, color, font-size, font-family, padding, width (see Button width), border-radius, border (see Borders and corners). |
mj-divider | DividerBlock | border-color, border-width, width (see Divider width), padding. |
mj-spacer | SpacerBlock | height. |
mj-social (with mj-social-element) | SocialIconsBlock | Each 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) | MenuBlock | Each link → MenuItemData. |
mj-table | TableBlock | Maps <tr>/<td>/<th> rows and cells to Templatical's table data; a leading <th> row sets hasHeaderRow. |
mj-raw | HtmlBlock | Inner markup preserved verbatim. |
mj-wrapper | SectionBlock.wrapper | The 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-accordion | HtmlBlock | Converted to an HTML block with the original markup preserved. |
mjml | Template settings | lang → locale; dir → direction, kept only when it differs from the direction the language implies. |
mj-head content | Template settings | mj-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 width | DividerBlock.width | Status |
|---|---|---|
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 span | the px number | converted |
| px, as wide as the line can span or wider | "full" | converted |
px, below 0 | clamped to 0 | approximated |
| 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 width | ButtonBlock.width | Status |
|---|---|---|
| missing | none, so the button fits its label | converted |
100% | "full" | converted |
px, above 0 | the px number | converted |
a percentage under 100%, such as 50% | the px it comes to | approximated |
above 100% | "full" | approximated |
px or a percentage that comes to 0px or less | none, so the button fits its label | approximated |
| any other value | none, so the button fits its label | approximated |
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-radius | Corner radius | Status |
|---|---|---|
one to four px values, such as 8px or 8px 8px 0 0 | the same corners, one number when all four match | converted |
any other value, such as 50%, 1em or 8px / 4px | square corners | approximated |
border or border-<side> | That side | Status |
|---|---|---|
a px width, solid, dashed or dotted, and a colour | the same | converted |
style none or hidden, no style, or a 0 width | not drawn | converted |
style double, groove, ridge, inset or outset | drawn solid | approximated |
a width that is not px, such as 2em or thin, or no width | not drawn | approximated |
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) becomescover; any other length or percentage becomesauto. - 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,roundandspacebecomerepeat.
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 —
SocialIconsBlocksupports three sizes (24px, 32px, 48px). Anmj-social-element'sicon-sizeoutside those three resolves to the nearest one. - Heading levels — an
<h5>or<h6>insidemj-textclamps to heading level 4, the highest aTitleBlocksupports. - Divider width and alignment — an
mj-dividerwidthoutside 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-buttonwidthgiven as a percentage other than100%becomes the px it comes to, one above100%becomes"full", and one that is unreadable or comes to0pxor less leaves the button fitting its label. See Button width. - Borders and corners — a
border-radiusthat 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 onmj-wrapperis dropped. See Borders and corners. - Background images — a
background-size,background-positionorbackground-repeatwith no exact Templatical keyword maps to the nearest one. See Background images. - Video blocks — a
VideoBlockrenders the same way a linkedImageBlockdoes, so nothing in the MJML marks it as video. Importing that markup back produces anImageBlockholding the same thumbnail and link; content survives, the block type does not. - HTML blocks — for the same reason, an
HtmlBlock's content renders as plainmj-textmarkup with nothing marking it as HTML. Importing it back produces aParagraphBlockholding 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 itspathattribute. Inline the included content before importing.- Custom MJML components — an unrecognized
mj-*tag lands as anHtmlBlockholding 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.