Skip to main content

Migrating from React Email

If your emails are React components, they can stay React components. @better-email/react-email renders an existing React Email project into the Better Email file format, which better ds push then ships.

Each component gains one export describing which parts a marketer may edit. Everything else — your component library, your TypeScript config, your imports and helpers — carries on working, because the adapter compiles and renders your components the way your own build does.

betteremail/react-email-starter is a working example of everything on this page: three modules, MIT licensed.

Install

In the React Email project:

npm install -D @better-email/react-email @react-email/render

React 18 or 19 and @react-email/render 2 are peers — the adapter uses the copies your project already has, so the output matches what your own preview renders. Node 20.16 or newer on the 20.x line, or 22.3 or newer.

The package installs a better-react-email binary with two commands:

better-react-email build # render components into the design system tree
better-react-email init # scaffold the config, a design system base, one example module

init refuses to overwrite anything that already exists.

Configure

better.react-email.config.mjs:

export default {
modules: "src/modules/**/*.{tsx,jsx}",
designSystemDir: "better-src",
out: "better",
};

Those are the defaults.

KeyMeaning
modulesGlob for the components that become modules.
designSystemDirWhere design-system.json, base.liquid, and settings.json live. Copied through verbatim.
outWhere the design system tree is written.

better-react-email init writes a minimal set of the three design-system source files if you do not have them: a version 3 design-system.json with only the Main content zone, a base.liquid containing {{ content }}, and a settings.json with the canonical empty Global and Shared settings. After that they are ordinary files, documented in the file format reference.

A module is a component plus one export

Default-export the component and export its Better Email metadata as better:

import { Heading, Section } from "@react-email/components";
import { liquid, liquidTag } from "@better-email/react-email";
import type { BetterModule } from "@better-email/react-email";

export const better = {
key: "heading",
name: "Heading",
settings: [
{
key: "heading",
name: "Heading",
inputs: [
{ key: "text", name: "Text", type: "text", defaultValue: "Section title" },
{ key: "show", name: "Show", type: "boolean", defaultValue: true },
],
},
],
} satisfies BetterModule;

export default function ModuleHeading() {
return (
<Section style={{ padding: "24px 32px" }}>
{liquidTag("if heading.show")}
<Heading style={{ color: "#0f1c2e", fontSize: "22px", margin: 0 }}>
{liquid("heading.text")}
</Heading>
{liquidTag("endif")}
</Section>
);
}

satisfies BetterModule is the point of the typed export: the settings and inputs are checked as you write them, and your editor completes the field names, rather than the build being the first thing to tell you a key is missing.

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.

Settings and inputs are the objects documented under settings, written as TypeScript. useFeed and repeatable default to false. Setting keys must be unique across the whole design system 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:

ObjectGenerated id
Modulere_<moduleKey>
Settingre_<moduleKey>_<settingKey>
Inputre_<moduleKey>_<settingKey>_<inputKey>

They are deterministic, so the same source produces the same ids everywhere 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.

Liquid placeholders

A marketer-editable value is a Liquid placeholder in the shipped module, but {{ }} written as JSX text would be escaped by React on its way to HTML. liquid() and liquidTag() solve that:

{liquid("hero.heading")} becomes {{ hero.heading }}
{liquid("hero.button_url.url")} becomes {{ hero.button_url.url }}
{liquidTag("if article.show_rule")} becomes {% if article.show_rule %}
{liquidTag("endif")} becomes {% endif %}

Both return a plain string, so they work anywhere a string does — as element children, and equally as a prop:

<Button href={liquid("hero.button_url.url")}>{liquid("hero.button_label")}</Button>

What they actually return is an encoded marker rather than the Liquid itself. React renders the marker verbatim, with nothing for its HTML escaping to mangle, and better-react-email build swaps every marker back for the Liquid it stands for as the last step. The one visible consequence: running React Email's own dev preview shows that marker text where a placeholder will be. Use better ds dev on the output directory to preview with real inputs.

Addressing follows the normal rules — a module's own settings unprefixed, brand tokens as global.*, design-system-level settings under template.*. See addressing values in Liquid.

Preview props

The build renders each component once. If yours takes props, hang the values it should render with on the component as PreviewProps — the same convention React Email uses for its own previews:

function Hero({ tone = "quiet" }: { tone?: string }) { /* … */ }

Hero.PreviewProps = { tone: "loud" };

export default Hero;

Without PreviewProps the component is rendered with no props at all, so give every prop a default or supply it here.

What your project can rely on

Each module is compiled with esbuild before it is rendered, using your own tsconfig.json. In practice that means the things you would expect to work do:

  • TypeScript, including satisfies, and JSX in .tsx or .jsx.
  • Path aliases from compilerOptions.paths@components/Panel resolves the way it does in your app.
  • Shared components and helpers imported from elsewhere in the repo. Local files are bundled into the module; packages from node_modules stay external and are loaded from your project, so a component library keeps its own runtime behaviour.
  • Wrapped exports. React.memo(...) and forwardRef(...) default exports render, and PreviewProps set on the wrapper is still picked up.

What the build produces

better-react-email build
Built 3 modules in /Users/you/acme-emails/better

The component above comes out as modules/heading/module.liquid:

<table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="padding:24px 32px"><tbody><tr><td>{% if heading.show %}<h1 style="color:#0f1c2e;font-size:22px;margin:0">{{ heading.text }}</h1>{% endif %}</td></tr></tbody></table>

React Email's components expanded into the table markup email clients need, styles inline on the elements, and both helpers back to Liquid. Only the rendered body is kept — the surrounding document, doctype, and any <head> React Email produced are dropped, because a module is a fragment that renders inside your template base.

Alongside it the build writes modules/heading/module.json with the id, key, name, hidden, and metadata, and modules/heading/settings.json with the settings from the better export and their filled-in ids:

{
"$schema": "https://app.better.email/schemas/settings.schema.json",
"settings": [
{
"key": "heading",
"name": "Heading",
"inputs": [
{
"key": "text",
"name": "Text",
"type": "text",
"defaultValue": "Section title",
"id": "re_heading_heading_text"
},
{
"key": "show",
"name": "Show",
"type": "boolean",
"defaultValue": true,
"id": "re_heading_heading_show"
}
],
"id": "re_heading_heading",
"useFeed": false,
"repeatable": false
}
]
}

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 better export rather than the filename, so numbering the sources (10-hero.tsx, 20-article.tsx) sets the picker order without changing any key or id.

Styles that cannot be inline

Because only the rendered body survives, anything React Email would have put in a document <head> does not reach the module. Media queries and prefers-color-scheme rules therefore belong in the <head> of better-src/base.liquid, where they apply to every module. 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 — 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-adapter-manifest.json:

{
"version": 1,
"modules": ["article", "footer", "hero"]
}

On each build, modules in the glob are rebuilt and listed there; a module that was in the manifest and has since disappeared from the glob has its directory deleted, because you deleted its source; and every other module directory in out is left alone and appended to modules.order after the built ones. better/.better/ — the CLI's binding — is never touched.

So a design system can be half React 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. Preserved modules have to be listed in modules.order already, which is what better ds pull gives you.

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 better export:

export const better = {
id: "cmp_legacy_banner",
key: "legacy_banner",
name: "Legacy banner",
settings: [/* … */],
} satisfies BetterModule;

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.

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-react-email build
cd better && better check && better ds push

better-react-email build --quiet suppresses the summary line for automation.

better ds dev works from the output directory, so you can keep a live preview open while you edit components — rerun the build and the preview picks up the new tree. That is also the preview to trust: React Email's own dev server does not know about Liquid.

Commit the React 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-react-email 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.