maximedubourg.com

Write dynamic email templates like a breeze with MJML in React

07/12/2022

Writing good looking emails is usually not the first task of your product backlog, but eventually, every product ends up needing it.

If you ever looked up at what an email's source code looks like, you wouldn't even consider writing it in plain HTML (really, you can't be that crazy)...

<!-- [...] -->

<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:504px;" ><![endif]-->
<table cellpadding="0" cellspacing="0" style="...">
  <tbody>
    <tr>
      <td
        style="direction:ltr;font-size:0px;padding:0 48px 28px;text-align:center;"
      >
        <!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:504px;" ><![endif]-->
        <table cellpadding="0" cellspacing="0" style="...">
          <!-- no flexbox or grid... -->
          <!-- just tables in tables in tables... -->
        </table>
      </td>
    </tr>
  </tbody>
</table>

<!-- [...] -->

a foretaste of hell (circa 2022, colorized)


So, how then?

MJML to the rescue

Fortunately, MJML was created to save our souls. It's an awesome framework that abstract most of the pain of writing responsive HTML emails by providing a set of stylable components (buttons, columns, images...) that you can reuse to write the markup of your emails.

<mjml>
  <mj-body>
    <mj-section>
      <mj-column>
        <mj-image width="100px" src="/logo.png"></mj-image>
        <mj-divider></mj-divider>
        <mj-text font-family="helvetica">Hello World</mj-text>
      </mj-column>
    </mj-section>
  </mj-body>
</mjml>

To install it from npm, without surprise:

npm i mjml

MJML also provides a way to include external .mjml files in your templates using <mj-include>, which allows you to reuse some markup in several emails.

But as MJML is not a templating language, there are things it doesn't provide, and that you might need:

So as long as you have a single email template to write, or that none of you emails needs such templating features, MJML by itself is great, and sufficient. But as soon as you need to go a bit further, you'll need to pair it with a templating engine, like React.

Using React templating features with MJML

Even if React is mostly known as a reactive view library, it provides all the features you would expect from a templating engine and a composition library.

npm i react react-dom

So let's say we have to write several email templates that all share the same Header, main content, and Footer, and that only some text change between them:

1. First, we need components

Let's start by creating our three components (only Header.tsx is detailed here, but the principle is the same for other components):

components/Header.tsx

export function Header({ title, logo }) {
  return (
    <mj-section background-color="#fff">
      <mj-column>
        {logo && <mj-image width="150px" src={logo} />}
        <mj-text>{title}</mj-text>
      </mj-column>
    </mj-section>
  );
}

components/MainContent.tsx

export function MainContent({ content, buttonLabel }) {
  // ...
}

components/Footer.tsx

export function Footer() {
  // ...
}

2. Then, use these components to create our email templates

Each email template is just a React component that reuses the components we just created. Here's an example for a welcoming email sent to our customer after they just signed up:

WelcomeEmail.tsx

import { Header } from "components/Header";
import { MainContent } from "components/MainContent";
import { Footer } from "components/Footer";

export function WelcomeEmail() {
  return (
    <mjml>
      <mj-body>
        <Header
          title="Thank you for your inscription and welcome!"
          logo="https://example.com/logo.png"
        />

        <MainContent
          content="We are happy to count you amongst our beloved customers."
          buttonLabel="Sign in to access your account"
        />

        <Footer />
      </mj-body>
    </mjml>
  );
}

And so on for other email templates, we just need to change the props passed to components!

3. Finally, let's build all that

As our goal is to build static HTML email templates, we won't need any of React's runtime-related features, so we will use ReactDOM's renderToString function to transform our React templates into MJML templates, and finally to HTML templates.

No need for a complicated build tool here, a good ol' NodeJS script will do the trick (but written in TypeScript, we're not savages):

// scripts/build.tsx

import * as React from "react";
import { renderToString } from "react-dom/server";
import mjml2html from "mjml";
import { join, relative } from "node:path";
import { mkdirSync, writeFileSync } from "node:fs";

const DIST_DIR = join(__dirname, "dist");
const TEMPLATES = {
  welcome: WelcomeEmail,
  // just add other templates here like: <filename>: TemplateComponent
};

// ensure dist dir before outputing our built templates
mkdirSync(DIST_DIR);

for (const name of Object.keys(TEMPLATES)) {
  const TemplateComponent = TEMPLATES[name];

  // building our React template component to get MJML markup
  const mjml = renderToString(<TemplateComponent />);

  // then building the MJML markup to get the final HTML email template
  const { html } = mjml2html(mjml, { validationLevel: "strict" });

  // writing the final HTML template to filesystem
  const htmlPath = join(DIST_DIR, `${name}.html`);
  writeFileSync(htmlPath, html, { encoding: "utf-8" });
  console.debug(`Wrote ${relative(__dirname, htmlPath)}`);
}

As we wrote our templates/components and our build script in TypeScript, we must run it with a tool like tsx.

# install tsx
npm i tsx -D

# run the build script
tsx scripts/build.tsx

4. Optional: Get a nicer development experience

As every project, we might need to iterate a bit while developing, so let's get a simple but comfortable environment to work with:

npm i simple-hot-reload-server concurrently -D

Then, in package.json:

{
  // ...
  "scripts": {
    "prepare": "mkdir -p dist/",
    "build": "tsx scripts/build.tsx",
    "build:watch": "tsx watch scripts/build.tsx",
    "serve": "npm run prepare && hrs dist/",
    "dev": "concurrently --kill-others npm:build:watch npm:serve"
  }
  // ...
}

By running npm run dev, we'll now have automatic hot-reload (upon changes) of our built templates served locally on the 8080 port.

Conclusion