An MJML headless CMS
your devs won't fight.
Visual MJML editor with reusable blocks, seasonal variants, full version history and deterministic locale fallback. Templates live behind a content API — your app posts events, the renderer compiles MJML on the fly.
Not a campaign tool. Not a Mailchimp template gallery. A real headless CMS for MJML, sized for transactional traffic.
- Visual editor, MJML output
- Reusable blocks · live inheritance
- Variants · version history
- Multi-locale fallback
Thirty MJML templates in /emails. A footer change means thirty pull requests, thirty deploys, thirty chances to miss one. Marketing tools won't host raw MJML; transactional providers want you to upload finished HTML and stop asking questions. So the templates end up in the app repo, half hand-written, half copy-pasted, drifting from each other a little more every quarter.
Templates and blocks live behind a content API. The footer is a block; thirty templates reference it. Edit the block once, every template that imports it picks up the change at the next render — no redeploy, no find-and-replace, no template fork. Variants and locales are first-class so seasonal copy and translations don't fork the tree either.
// the difference
MJML in your repo vs. MJML in a content API.
Left: the way most teams own MJML today — files in /emails, hand-imported partials, hope nothing drifts. Right: the same idea behind a headless CMS — your app posts an event, the renderer assembles blocks and compiles MJML at request time.
// emails/welcome.mjml — one of thirty MJML files in your repo.
// Footer change? Open thirty PRs.
<mjml>
<mj-body>
<mj-include path="./_partials/header.mjml" />
<mj-section>
<mj-column>
<mj-text font-size="20px">Hi {{firstName}},</mj-text>
<mj-text>Welcome to {{brand}}.</mj-text>
</mj-column>
</mj-section>
<mj-include path="./_partials/footer.mjml" />
</mj-body>
</mjml>
// app.ts — render + send pipeline you maintain by hand.
import mjml2html from "mjml";
import Handlebars from "handlebars";
const tpl = readFileSync("emails/welcome.mjml", "utf8");
const filled = Handlebars.compile(tpl)({ firstName, brand });
const { html } = mjml2html(filled);
await postmark.sendEmail({ HtmlBody: html, ... });
// One POST. Templates and blocks live behind a content API.
// Footer change? One save in the editor — every template re-renders.
await fetch("https://app.else.events/api/events", {
method: "POST",
headers: {
"X-API-Key": process.env.ELSE_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
eventId: crypto.randomUUID(),
eventName: "user.signed_up",
occurredAt: new Date().toISOString(),
payload: {
user: { email, firstName },
brand: "acme",
},
}),
});
// Behind the API: rule matches the event → renderer pulls the
// template, resolves block imports (header, footer, CTA) at render
// time, compiles MJML, ships through Postmark / SMTP, webhooks back. The /emails snippet is the rough shape of how teams typically own MJML before they hit the wall. Real-world variations exist (Handlebars, EJS, mjml-react). The point is the shape — files in your repo, partial imports, redeploy to ship a copy change.
Five things a CMS for MJML actually has to do.
Most “transactional email tools” do the last 20% (send + webhook). The headless-CMS surface is the first 80% — and the part you keep rebuilding by hand otherwise.
-
Visual MJML editor
Outline tree on the left, live iframe preview on the right, property pane on the right edge. Compiles to MJML on save, so the same template renders consistently in Outlook, Gmail and Apple Mail without you hand-writing a single table layout.
-
Reusable blocks · live inheritance
Header, footer, CTA, signature blocks as first-class entities. Edit the block once, every template that references it picks up the change. Cycle-detected, depth-limited, tenant-scoped — so a “footer” block can't quietly recurse into itself or leak across workspaces.
-
Variants · version history
Ship seasonal variants (Easter, Black Friday, end-of-year) without forking the template. Every save is a version with a diff and one-click rollback, so an accidental copy change at 3 a.m. never becomes an outage.
-
Multi-locale, deterministic fallback
One template, multiple languages. Locale is resolved per send; missing translations follow a fixed chain (locale → workspace default → empty), so you'll never ship an email with a literal {{i18n.greeting}} placeholder.
-
Content API for templates
Templates, blocks, variants and locales are addressable via REST. Sync your design-system colours from CI, version-pin a block in production, generate a JSON snapshot for audits — without leaving your build pipeline.
// proof
Edit a block. Watch every template update.
The interactive demo below is the same control surface we use in the editor. Each tenant pin swaps a small set of brand variables; the same logic powers reusable blocks and locales — change one source, every template that references it re-renders.
// Live inheritance demo
Pick a brand. Every email updates.
One block, three templates, three tenants. The header block reads the active tenant variables — change the tenant and the same template renders in their colours, with their logo, no fork required.
- welcome.email Acme
Welcome to Acme
Hi Alex — your Acme workspace is ready.
- invoice.email Acme
Your Acme invoice
Your latest Acme invoice is attached.
- receipt.email Acme
Thanks for your Acme order
Receipt #4012 from Acme.
Things to know before you migrate.
Do my templates have to live in else.events?
If you want the headless-CMS surface (visual editor, reusable blocks, version history, locale fallback), yes — that's where the abstraction lives. The output is still standard MJML, so you're never locked in: you can export any template as MJML at any time and ship it elsewhere.
Can I import my existing /emails folder?
We don't have a one-click importer yet. The pragmatic path: drop your existing MJML into the editor's source view per template, then refactor shared parts (header / footer / CTA) into reusable blocks one at a time. Most teams migrate the noisy templates first and leave the rare ones alone.
How does versioning work?
Every save is a version, scoped to the template (or block, or variant). The dashboard shows a diff and a one-click rollback. Versions are immutable; rolling back creates a new version pointing at the older content, so the audit trail stays linear.
What about email-client quirks (Outlook, Gmail clipping)?
MJML handles the table layout for you, which covers the bulk of Outlook breakage. Gmail's 102 KB clipping limit is surfaced as a warning in the editor before you ship. Dark-mode previews and the major mobile clients are part of the preview matrix.
Is the rendered HTML cached?
No. Each event re-renders from the current block tree, so a footer change is live the next time you POST — no cache invalidation step, no “stale template” class of bug. Block-level caching is on the roadmap for high-traffic plans where the trade-off makes sense.
Stop maintaining 30 templates by hand.
14-day free trial, no credit card. Drop a few templates into the editor, refactor the shared parts into blocks, ship a footer change in one place — see whether the model fits your stack before you commit.