Migrating from Maizzle
If your emails are already built with Maizzle, you do not have to choose between your toolchain and a design system. @better-email/maizzle renders your existing Maizzle project into the Better Email file format, which better ds push then ships.
Maizzle keeps doing what it does: Tailwind compilation, component expansion, CSS inlining. The adapter adds one thing your fragments do not have today — a declaration of which parts a marketer is allowed to edit — and emits the module tree around it. Your git history, your build, and your components survive the move.
betteremail/maizzle-starter is a working example of everything on this page: three annotated modules, an ordinary Maizzle config, MIT licensed.
Install
In the Maizzle project:
npm install -D @better-email/maizzle @maizzle/framework@^5
Maizzle 5 is a peer dependency — the adapter calls the render() your project already has, so the version you build with is the version it uses. Node 20.16 or newer on the 20.x line, or 22.3 or newer.
The package installs a better-maizzle binary with two commands:
better-maizzle build # render modules into the design system tree
better-maizzle init # scaffold the config, a design system base, one example module
init refuses to overwrite anything that already exists, so it is safe to run in a project that is partway set up.
Configure
better.maizzle.config.mjs sits next to your Maizzle config:
export default {
modules: "src/modules/**/*.html",
designSystemDir: "better-src",
out: "better",
// maizzle: "./config.js",
};
Those are the defaults, so the file can stay this short.
| Key | Meaning |
|---|---|
modules | Glob for the fragments that become modules. |
designSystemDir | Where design-system.json, base.liquid, and settings.json live. Copied through verbatim. |
out | Where the design system tree is written. |
maizzle | Path to a specific Maizzle config. Omit it and the adapter loads config.js, deep-merging config.production.js over it when both exist. |
All three of the design-system source files are required. better-maizzle init writes a minimal set: a version 3 design-system.json with only the Main content zone, a base.liquid containing {{ content }}, and a settings.json holding the canonical empty Global and Shared settings. From there they are ordinary files — the same ones documented in the file format reference, and the same ones better ds pull writes.
Annotate a module
A module is a normal Maizzle fragment with a better: block in its YAML front matter:
---
better:
key: hero
name: Hero
settings:
- key: hero
name: Hero
inputs:
- key: heading
name: Heading
type: text
defaultValue: Build emails like software
- key: button_url
name: Button link
type: link
defaultValue:
url: https://example.com
---
<style>
@tailwind utilities;
</style>
<table role="presentation" class="w-full bg-[#0F1C2E]" cellpadding="0" cellspacing="0" border="0">
<tr>
<td align="center" class="px-8 py-12">
<h1 class="m-0 text-[32px] leading-[40px] font-bold text-white">@{{ hero.heading }}</h1>
<a href="@{{ hero.button_url.url }}" class="inline-block rounded-md bg-[#E8A33D] px-7 py-3.5 font-bold text-[#0F1C2E] no-underline">Get started</a>
</td>
</tr>
</table>
The better block accepts exactly six fields — id, key, name, hidden, metadata, and settings — and rejects anything else by name, so a typo is a build error rather than a silently ignored line. key and name are required; hidden defaults to false and metadata to {}.
Module keys are lowercase and underscore-separated: a letter first, then letters and digits in segments joined by single underscores. hero, feature_row, product_card_2.
Settings and inputs are the same objects documented under settings, written as YAML instead of JSON. useFeed and repeatable default to false. Setting keys must be unique across the whole design system — the template base and every module together — and input keys unique within their setting; the build checks both and names the file.
Ids are optional
Leave ids out and the adapter derives them from the keys:
| Object | Generated id |
|---|---|
| Module | mz_<moduleKey> |
| Setting | mz_<moduleKey>_<settingKey> |
| Input | mz_<moduleKey>_<settingKey>_<inputKey> |
They are deterministic, so the same front matter produces the same ids on every machine and every build, and the platform sees edits rather than a delete followed by an add.
Write an id explicitly when you are adopting a module that already exists in the design system — see taking over existing modules below. The id template is reserved for the design system's own preview and is rejected.
Liquid inside a Maizzle fragment
Maizzle's own expression syntax also uses {{ }}, so a Better Email Liquid output written plainly would be evaluated at build time and disappear. Use Maizzle's escape, @{{ ... }}, and the build emits a literal {{ ... }}:
<h1>@{{ hero.heading }}</h1>
Liquid tags need no escape. {% if %}, {% for %}, {% assign %} and the rest pass through untouched:
{% if article.show_rule %}
<td class="pt-6"><div class="h-px w-full bg-[#E4E9EF]"></div></td>
{% endif %}
Addressing follows the normal rules: a module's own settings unprefixed, brand tokens as @{{ global.color_brand }}, design-system-level settings as @{{ template.page.background_color }}. See addressing values in Liquid.
The whitespace-control trap applies to the emitted module.liquid, so it applies to your fragment body. Fragments that begin and end on markup — which is the normal shape — are safe. A fragment whose body starts with {%- if ... %} or ends with {%- endif -%} produces a module that renders as nothing.
What the build produces
better-maizzle build
Built 3 modules in /Users/you/acme-emails/better
Each module is compiled on its own. The adapter takes your Tailwind content configuration and replaces the entry matching the module glob with that module's body, leaving your other raw, file, and glob entries in place — so every module gets exactly the utilities it uses and nothing from its neighbours. Inlining is on by default for module builds (an explicit css.inline in your Maizzle config still wins), because a module is a fragment with no <head> of its own to carry a stylesheet.
The fragment above comes out as modules/hero/module.liquid:
<table role="presentation" class="w-full bg-_0F1C2E" cellpadding="0" cellspacing="0" border="0" style="width: 100%; background-color: rgb(15, 28, 46);">
<tr>
<td align="center" class="px-8 py-12" style="padding-left: 2rem; padding-right: 2rem; padding-top: 3rem; padding-bottom: 3rem;">
<h1 class="m-0 text-32px leading-40px font-bold text-white" style="margin: 0px; font-size: 32px; font-weight: 700; line-height: 40px; color: rgb(255, 255, 255);">{{ hero.heading }}</h1>
<a href="{{ hero.button_url.url }}" class="inline-block rounded-md bg-_E8A33D px-7 py-3_5 font-bold text-_0F1C2E no-underline" style="display: inline-block; border-radius: 0.375rem; background-color: rgb(232, 163, 61); padding-left: 1.75rem; padding-right: 1.75rem; padding-top: 0.875rem; padding-bottom: 0.875rem; font-weight: 700; color: rgb(15, 28, 46); text-decoration-line: none;">Get started</a>
</td>
</tr>
</table>
Every utility became a declaration in a style attribute, the @tailwind utilities block is gone, and @{{ hero.heading }} is now Liquid. The leftover class attributes are inert: Maizzle rewrites class names into a form email clients tolerate — bg-[#0F1C2E] becomes bg-_0F1C2E, py-3.5 becomes py-3_5 — and leaves them in the markup.
Alongside it the build writes modules/hero/module.json with the id, key, name, hidden, and metadata, and modules/hero/settings.json with the settings from the front matter and their filled-in ids. design-system.json, base.liquid, and settings.json are copied from designSystemDir unchanged.
modules.order — the order marketers see in the Campaign Editor's module picker — follows the source file paths, sorted. The module key comes from the front matter rather than the filename, so numbering the sources (10-hero.html, 20-article.html) sets the picker order without changing any key or id.
Responsive utilities are the exception
A utility that cannot be inlined — anything behind a media query, such as sm:text-3xl — stays in a residual <style> block in that module's module.liquid, with the rules that were inlined and the ones nothing uses stripped out. That works in clients that honour embedded CSS in a fragment, which is not all of them.
For responsive behaviour you want everywhere, define the helper classes once in the <head> of better-src/base.liquid and use them from modules. That is what Meridian does with its sm-* and dm-* classes.
Nothing is written until the whole tree validates
Before it writes a single file, the build assembles the complete output tree in memory and runs it through the same serializer better ds push uses, plus the content-zone checks. A missing {{ content }} in base.liquid, a module claiming an undeclared content zone, a duplicate setting key, a malformed default value — all of them fail the build with the file named, and the previous contents of out are left exactly as they were.
Working alongside pulled modules
The build records what it owns in better/.better-maizzle-manifest.json:
{
"version": 1,
"modules": ["article", "footer", "hero"]
}
That manifest is the whole ownership model. On each build:
- modules in the glob are rebuilt and listed in the manifest;
- a module that was in the manifest and has since disappeared from the glob has its directory deleted, because you deleted its source;
- every other module directory in
outis left completely alone and appended tomodules.orderafter the built ones.
So a design system can be half Maizzle and half hand-maintained. Pull the whole thing into out once, point the glob at the modules you have converted, and the rest keep working untouched until you get to them. better/.better/ — the CLI's binding — is never touched either.
Preserved modules have to be listed in modules.order already, which is exactly what better ds pull gives you. Hand-creating a directory in out without adding its line produces Add "<key>" to modules.order; this module directory is unlisted.
Taking over existing modules
Converting a module that already lives on the platform means keeping its id, so campaigns using it follow the change instead of losing their content. Copy the id from the pulled module.json into the front matter:
better:
id: cmp_legacy_banner
key: legacy_banner
name: Legacy banner
The build then adopts the directory, rewrites it from your Maizzle source, and adds it to the manifest.
Adoption requires an exact match on both id and key. If they differ, the build stops rather than clobbering a module you did not mean to replace:
src/modules/legacy_banner.html: Module identity id "mz_legacy_banner", key "legacy_banner" does not
match existing id "cmp_legacy_banner", key "legacy_banner" in better/modules/legacy_banner/module.json;
only an exact identity match can be adopted without prior ownership in better/.better-maizzle-manifest.json.
The loop
Bind the output directory to the design system once:
npm install -g @better-email/cli
better login
better ds pull <id> --dir better
Then it is build, check, push:
better-maizzle build
cd better && better check && better ds push
better-maizzle build --quiet suppresses the summary line for automation.
better ds dev works from the output directory too, so you can keep a live preview open while you edit the Maizzle sources — rerun better-maizzle build and the preview picks up the new tree.
Commit the Maizzle side and generate the rest: the starter's .gitignore is node_modules/ and better/. The output directory is build output, and hand-editing it is a change your next build discards.
In CI, run better-maizzle build before better check so the job validates what the build produces rather than a stale tree, and keep the push job pointed at the output directory.
Related docs
- File format
- Better CLI
- Migrating from React Email — the same shape, for a React project
- CI recipes
- maizzle-starter — the worked example, MIT licensed
- Settings