CodeWorlds
Back to collections
Guide17 min readCodeWorlds Team

React Email, message templates built from components

React Email turns JSX components into HTML for mail clients. Version 6.9.2, MIT licence, local preview, and the real limits of Tailwind inside an email.

React Email, message templates built from components

React Email is a set of React components plus a command line tool that turn a JSX template into HTML that mail clients understand. The react-email package sits at version 6.9.2, released on 7 August 2026, the resend/react-email repository holds roughly 19.6 thousand stars, and the licence is MIT with no paid variant.

Why email HTML plays by different rules

A web page has one technical recipient: the browser. An email has a dozen or more, and none of them has to agree with the others. The same code lands in Gmail on the web, Gmail in a phone app, Outlook on Windows, Apple Mail, Thunderbird and a long tail of national providers. Each one decides on its own how much of your CSS gets through.

The consequences are concrete. A stylesheet linked from an external address is out, because a mail client will not fetch it. JavaScript is out entirely. Rules written inside a <style> tag work unevenly, which the Tailwind component documentation states plainly while pointing at the caniemail.com database and its support table for that feature. CSS variables have poor support. Even colour notation matters: the rgb(255 255 255 / 1) form with spaces, which modern Tailwind uses, is not widely handled, and the safe form is rgb(255,255,255) with commas.

Since you cannot rely on a stylesheet, styles go into the style attribute of every element. Since you cannot rely on flex or grid, layout is described with nested tables. That is how hand-written email templates looked for the past two decades, and that is still what the output handed to the recipient looks like.

React Email does not change those rules, because they cannot be changed. It only moves the place where you work. You write components, and the rendering layer produces tables and attribute styles. Confirming that takes a moment: unpack @react-email/section, @react-email/row and @react-email/container, and the compiled code contains a literal table element. These are not semantic names dressing up something else, they are table generators.

There is one exception to the attribute-style rule. Media queries, meaning @media, cannot be written into a style attribute, so they have to land in a <style> tag inside <head>. The Tailwind component documentation describes this as a deliberate compromise, along with a second decision: class names in that tag are sanitised to avoid characters requiring backslash escapes, because those can break a fair number of clients.

Version, licence and package composition

Telling three packages apart saves considerable confusion on the first install.

The first is react-email at version 6.9.2, published on 7 August 2026. This is the command line tool, exposing the email command. Inside sit esbuild, chokidar, socket.io, commander, tailwindcss plus @babel/parser and @babel/traverse, the full kit needed for a preview server with live reload. A curiosity from the startup code: the executable checks whether the Node process received the --experimental-vm-modules and --disable-warning=ExperimentalWarning flags, and if not, relaunches itself with them. You never have to remember those flags, but if you wrap this in a script, you now know where the extra child process comes from.

The second is @react-email/components at version 1.0.12, the aggregate package. It renders nothing by itself and merely pulls in twenty separate packages: body, button, code-block, code-inline, column, container, font, head, heading, hr, html, img, link, markdown, preview, render, row, section, tailwind and text. Each carries its own version number and its own release cadence.

The third is @react-email/render, which is both a dependency of the two above and a standalone package. The current release in the registry is 2.1.0, while @react-email/components 1.0.12 pins 2.0.6 internally. If you audit dependency versions in your project, that gap is normal and follows from the aggregate package pinning exact versions as of its own release.

The licence is unambiguous, and that is rare enough to record. The LICENSE.md file at the repository root carries the full MIT text with the notice "Copyright 2024 Plus Five Five, Inc". The license field in the npm registry for react-email, for @react-email/components and for the component packages reads MIT. The GitHub programming interface reports MIT License for the repository with the SPDX identifier MIT. There is no directory holding a separate commercial licence, no dual choice, no add-on whose installation drags in a proprietary package. All the functionality lives in the open release.

Project health: 19638 stars, 1076 forks, 31 open issues, the last change on the main branch dated 19 August 2026, the repository created in September 2022 and not archived. Environment requirements are mild, since peer dependencies accept React 18 or 19 along with a matching react-dom.

Installation and local preview

Entering an existing project looks like this.

Code
Bash
# the command line tool and the components
npm install --save-dev react-email
npm install @react-email/components

# preview server, defaults to ./emails and port 3000
npx email dev

# custom directory and port
npx email dev --dir ./src/emails --port 3001

# compatibility warnings for selected clients
npx email dev --clients gmail,outlook,protonmail

# write HTML files into the out directory
npx email export --outDir out --pretty

# plain text output instead of HTML
npx email export --plainText

# custom extension on the output files
npx email export --dir ./src/emails --extension blade.php

# a new project from scratch
npx create-email@latest

The email dev command starts a browser preview application listing the templates in the directory and reloading them on save. The --clients flag takes a comma-separated list and switches on compatibility warnings. Accepted values are gmail, outlook, yahoo, apple-mail, aol, thunderbird, microsoft, samsung-email, sfr, orange, protonmail, hey, mail-ru, fastmail, laposte, t-online-de, free-fr, gmx, web-de, ionos-1and1, rainloop and wp-pl, twenty-two entries including the Polish wp-pl. The same list can be supplied through the COMPATIBILITY_EMAIL_CLIENTS environment variable, which the flag overrides.

The email build and email start commands build and run the preview application itself, which the tool copies into a .react-email directory. They earn their keep when you want to expose template previews to a marketing team at a fixed address rather than asking people to run a server locally.

The one that matters most is email export. It takes templates from a directory and writes the result out as files. The --extension option lets you give them any extension you like, and the command documentation offers blade.php as the example. For judging vendor lock-in, that is the detail that settles the matter: the product of React Email is a plain text file you can hand to any system, including a template engine written in another language.

A separately published package, create-email at version 1.2.5, scaffolds a new project with sample templates. It has three dependencies and amounts to copying a starter directory.

Anatomy of a template

Below is a welcome template using components whose field names I checked against the published type declarations.

Code
TypeScript
import {
  Body, Button, Column, Container, Font, Head, Heading,
  Hr, Html, Img, Link, Preview, Row, Section, Text
} from '@react-email/components'

interface WelcomeEmailProps {
  userName: string
  activationUrl: string
}

export default function WelcomeEmail({ userName, activationUrl }: WelcomeEmailProps) {
  return (
    <Html lang="en" dir="ltr">
      <Head>
        <Font
          fontFamily="Inter"
          fallbackFontFamily={['Helvetica', 'Arial', 'sans-serif']}
          webFont={{
            url: 'https://example.com/fonts/inter-regular.woff2',
            format: 'woff2'
          }}
          fontWeight={400}
          fontStyle="normal"
        />
      </Head>
      <Preview>Confirm your address and start using your account</Preview>
      <Body style={{ backgroundColor: '#f4f4f5', margin: 0, padding: '24px 0' }}>
        <Container style={{ backgroundColor: '#ffffff', padding: '32px', maxWidth: '600px' }}>
          <Img
            src="https://example.com/logo.png"
            alt="Company logo"
            width="120"
            height="32"
          />
          <Heading as="h1" mt={24} mb={8} style={{ fontSize: '24px' }}>
            Hi {userName}
          </Heading>
          <Text style={{ fontSize: '16px', lineHeight: '24px', color: '#3f3f46' }}>
            One click left. Confirm your address to activate the account.
          </Text>
          <Section style={{ marginTop: '24px' }}>
            <Button
              href={activationUrl}
              style={{
                backgroundColor: '#2563eb',
                color: '#ffffff',
                padding: '12px 20px',
                borderRadius: '6px',
                msoPaddingAlt: '0px'
              }}
            >
              Activate account
            </Button>
          </Section>
          <Hr style={{ borderColor: '#e4e4e7', margin: '32px 0' }} />
          <Row>
            <Column>
              <Text style={{ fontSize: '12px', color: '#71717a' }}>
                Did not sign up? Ignore this message.
              </Text>
            </Column>
            <Column align="right">
              <Link href="https://example.com/help" style={{ fontSize: '12px' }}>
                Help
              </Link>
            </Column>
          </Row>
        </Container>
      </Body>
    </Html>
  )
}

Several details in that file deserve comment. Font has to sit inside Head, requires the fontFamily and fallbackFontFamily fields, and its webFont takes an object with a URL and a format drawn from woff, woff2, truetype, opentype, embedded-opentype and svg. Fallback values come from a closed set of system fonts, and array order is priority order. The type declaration itself warns that not every client supports web fonts and points at the compatibility database.

Preview accepts text only, because it generates a hidden fragment that mail clients show next to the subject in the message list. Without it you get the first words of the body there, often an image address or a greeting carrying no information.

Heading has an as field accepting h1 through h6 plus dedicated margin fields m, mx, my, mt, mr, mb and ml. Button is an ordinary anchor and takes every attribute of an a element, including href and target. That package also adds two of its own fields to the React.CSSProperties type, msoPaddingAlt and msoTextRaise, and that is the point where mail client quirks surface directly in the programming interface.

Tailwind CSS inside an email

The Tailwind component from @react-email/tailwind lets you write classes instead of style objects. The current version is 2.0.7 and it depends on tailwindcss at 4.1.18 or newer, meaning the fourth line of Tailwind CSS.

Code
TypeScript
import { Button, Container, Section, Tailwind, Text } from '@react-email/components'

export default function OfferEmail({ discountCode }: { discountCode: string }) {
  return (
    <Tailwind
      config={{
        theme: {
          extend: {
            colors: {
              brand: '#2563eb',
              muted: '#71717a'
            }
          }
        }
      }}
    >
      <Container className="mx-auto max-w-[600px] bg-white p-8">
        <Section>
          <Text className="text-[18px] font-semibold text-brand">
            Discount code: {discountCode}
          </Text>
          <Text className="text-[14px] text-muted">
            The code is valid until the end of the month.
          </Text>
          <Button
            href="https://example.com/shop"
            className="rounded-md bg-brand px-5 py-3 text-white"
          >
            Go to the shop
          </Button>
        </Section>
      </Container>
    </Tailwind>
  )
}

The config field takes a Tailwind configuration stripped of the content key, visible in the type declaration as Omit<Config, 'content'>. The content key is gone because there is no file scanning here: the component sees the React tree it wraps, and that is all it needs.

The mechanism is described in the package readme and is worth knowing before the output surprises you. Classes are turned into styles written into the style attribute of the rendered elements rather than into a shared stylesheet. Media queries, which cannot be inserted that way, land in a <style> tag inside <head> together with their associated class names, and those names get sanitised. CSS variables, on which Tailwind's fourth line rests a large part of its configuration, are resolved by a dedicated PostCSS plugin, and any it cannot resolve are left untouched. Colours written with the space syntax are rewritten with commas, and opacity given after a slash becomes the fourth argument of the rgb function.

The practical conclusion is that part of what Tailwind offers passes through cleanly and part quietly drops out. Spacing, colours, text sizes and rounded corners map onto simple properties and work. Anything resting on flex, grid or positioning calls for caution, because the class will be generated, the style will reach the attribute, and the mail client may still ignore it. Keep building layout with Section, Row and Column, and treat Tailwind as a more convenient way of writing values rather than a way of describing a grid.

Rendering and sending through any provider

All contact between the template and the outside world runs through one function.

Code
TypeScript
import { render } from '@react-email/render'
import nodemailer from 'nodemailer'
import WelcomeEmail from './emails/welcome'

const element = WelcomeEmail({
  userName: 'Anna',
  activationUrl: 'https://example.com/activate?token=abc'
})

const html = await render(element, { pretty: true })
const text = await render(element, {
  plainText: true,
  htmlToTextOptions: { wordwrap: 80 }
})

const transporter = nodemailer.createTransport({
  host: 'smtp.example.com',
  port: 587,
  auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS }
})

await transporter.sendMail({
  from: 'welcome@example.com',
  to: 'anna@example.com',
  subject: 'Confirm your address',
  html,
  text
})

The render function returns a promise resolving to a string. The option set is short and follows entirely from the Options type declaration: pretty formats the output through Prettier, plainText switches the output to text, htmlToTextOptions passes settings straight to the html-to-text library, and unstableTextConversion enables an in-house text converter instead of that library, at which point htmlToTextOptions is no longer available. The unstable prefix in the name is a warning, not decoration.

This brings us to the question that most often raises doubts. The project originated at Resend and belongs to the same company, as the licence notice shows, but it creates no runtime attachment whatsoever. The product of render is a string of HTML. You hand that string to any SMTP client or any provider HTTP interface as the html field. It works with Resend, with Postmark, with Loops, with your own mail server and with anything else that accepts HTML.

The only place Resend appears inside the tool itself is the pair of commands email resend setup and email resend reset. The first stores a programming interface key in the tool's configuration, the second removes it. It serves sending test messages from the preview. It is entirely optional, and without running those commands the tool reaches out nowhere.

Scope of responsibility is a separate matter. React Email does not send messages, does not keep recipient lists, does not handle unsubscribes, does not collect open statistics and does not manage sender domain reputation. Nor does it replace SPF, DKIM and DMARC authentication. This is a template layer, not a mail platform, and confusing the two leads to disappointment. Server-side rendering in Next.js or any Node environment is on you, at the point where you call the provider anyway.

React Email against the alternatives

FeatureReact EmailMJMLMaizzlejsx-emailHand-written HTML
Template languageJSX and React componentsits own XML tagsHTML with utility classesJSX and React componentsHTML written directly
Execution modelReact rendered to HTMLmarkup compiled to HTMLcompilation with CSS inliningReact rendered to HTMLno build step
Tailwind CSS integrationTailwind component, line 4outside the corebuilt in, the core of itavailable as an add-onnot applicable
Current version6.9.25.4.06.1.03.2.1not applicable
LicenceMITMITMITMITnot applicable

The choice comes down to one question: does your team write React anyway. If it does, React Email lets you keep templates in the same repository, in the same language and under the same type checking as the rest of the interface. Template properties are an ordinary TypeScript interface, so renaming a field in your data breaks compilation instead of breaking the message in someone's inbox.

If templates are written by somebody who does not know React, the arithmetic looks different. MJML has simpler syntax and does not require a Node environment during day-to-day content work. Maizzle is a good pick where the starting point is finished HTML and the job is mostly convenient style inlining. Hand-writing tables still makes sense for a single transactional template nobody has touched in two years, because every tool is one more dependency to keep updated.

Common mistakes

The first is writing a template the way you write a page. display: flex, position: absolute, background images and pseudo-elements carry no guarantee of working. Layout belongs to Section, Row and Column, which render tables, and that is the only construction you can rely on everywhere.

The second is skipping the plain text version. Some recipients and some filters see only that. A second render call with plainText: true and passing the result as the text field at send time is all it takes.

The third is images without dimensions and without alternative text. Many clients block image loading by default, so the recipient first sees a box holding the alt text. The src value has to be absolute and publicly reachable, since a relative path has nothing to resolve against here.

The fourth is leaving out the Preview component. Without it the message list shows the opening of the body, which is usually a greeting or the alternative text of the first image.

The fifth is leaning on CSS variables in the Tailwind configuration. The plugin resolves them before rendering, but any it cannot resolve stay in the output unchanged, and a mail client will make nothing sensible of them.

The sixth is mistaking a browser preview for an inbox test. The email dev server renders in a browser, an environment far more forgiving than the target. The --clients flag produces compatibility warnings, but the final check is still sending to real accounts.

The seventh is installing react-email alone and hunting for components in it. That package holds the command line tool. The components live in @react-email/components or in the individual packages and have to be added separately.

FAQ

Does React Email tie me to Resend?

Not technically. The product is a string of HTML you hand to any provider or SMTP server. The email resend setup and email resend reset commands are optional and only concern sending tests from the preview. The tie is one of ownership, since the project belongs to the company behind Resend, and that is worth keeping in mind when judging its direction.

Do I need React in my application to use React Email?

Not in the production front end, but you do need React and react-dom as dependencies in whatever environment renders the messages. Peer dependencies accept versions 18 and 19. You can also take the email export route and treat React Email as an HTML file generator run outside the application.

Does Tailwind really work in email?

Partly, and that is the honest answer. Classes turn into styles in the style attribute, media queries land in a <style> tag, colours are rewritten into syntax the clients handle, and CSS variables are resolved by a PostCSS plugin. Everything touching spacing, colour and typography passes through well. Layout resting on flex or grid remains risky.

How do I generate the plain text version?

With a render(element, { plainText: true }) call. Conversion runs through the html-to-text library by default, which you can tune through htmlToTextOptions. The unstableTextConversion option switches to the project's own converter, and then passing that library's settings is not available.

Does the local preview replace testing in mail clients?

No. The email dev server shows the template in a browser, which lets through far more than the target mail programs do. The --clients flag switches on compatibility warnings for twenty-two clients, wp-pl among them, but that is still static analysis rather than a real render.

Is React Email paid?

No. The react-email and @react-email/components packages and every component package carry the MIT licence in the npm registry, and the repository LICENSE.md holds the full MIT text. There is no commercial variant, no feature behind a surcharge and no usage cap. The sending service costs money if you choose one, but that is a separate product.

Documentation lives at react.email, the source code in the GitHub repository, and support tables for individual CSS properties in the caniemail database.

Read next

We use cookies to enhance your experience on the site