Back to Blog
๐ŸŽจ
/9 min read/By SMTPfast Team

Building Email Templates with React (No Framework Required)

Build readable, type-safe email templates with React Email. Covers setup, rendering, local preview, Tailwind, Outlook quirks, and plain text generation.

reacttemplatestutorialhtml
Share:๐•in

The Email Template Problem

1999
The era HTML email rendering is stuck in. No flexbox. No grid. Just tables and inline styles.

HTML email rendering hasn't moved on. You are writing <table> layouts with inline styles like it is the early internet, because Outlook still uses the Word HTML rendering engine and Gmail still strips <style> tags inside <head> on a forwarded message. You can't escape the constraints, but you can stop authoring against them by hand.

This post walks through React Email end to end: installing it, writing a typed template, rendering to HTML and plain text, previewing locally, using Tailwind, surviving Outlook, and shipping the result through the SMTPfast API.

TLDR

  • React Email gives you React components that compile to inline-style table HTML.
  • render(<Email />) returns the HTML string at runtime; render(<Email />, { plainText: true }) returns the text alternative.
  • react-email dev runs a local preview server on :3000 so you can iterate without sending real mail.
  • The <Tailwind> wrapper inlines Tailwind classes at render time. No build step needed.
  • Outlook still needs a few escape hatches (VML buttons, mso conditionals, fixed widths). Cover them once in a layout component and forget about them.
  • One plain text part per email. Always.

1. Install React Email

npm install @react-email/components @react-email/render
# Optional: the local preview server CLI
npm install --save-dev react-email

@react-email/components is the component library (Html, Body, Container, Button, Section, Text, Hr, Img, Link, Tailwind). @react-email/render is the small runtime that turns a React tree into the HTML string you actually send. They release together; pin them to matching versions in package.json.

If you want hot-reload preview, also install the react-email CLI as a dev dependency. It scans an emails/ folder at the project root by default.

2. Write a Typed Template

// emails/welcome.tsx
import {
  Html,
  Head,
  Body,
  Container,
  Section,
  Text,
  Button,
  Hr,
} from "@react-email/components";

export interface WelcomeEmailProps {
  name: string;
  dashboardUrl: string;
}

export default function WelcomeEmail({ name, dashboardUrl }: WelcomeEmailProps) {
  return (
    <Html>
      <Head />
      <Body style={body}>
        <Container style={container}>
          <Text style={heading}>Welcome, {name}</Text>
          <Text style={paragraph}>
            Thanks for signing up. Two minutes to get to your first delivered
            email:
          </Text>
          <Button href={dashboardUrl} style={button}>
            Open dashboard
          </Button>
          <Hr style={divider} />
          <Text style={footer}>
            You are receiving this because you created an account at our site.
          </Text>
        </Container>
      </Body>
    </Html>
  );
}

const body = {
  backgroundColor: "#f6f9fc",
  fontFamily:
    "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, sans-serif",
};
const container = { maxWidth: "600px", margin: "0 auto", padding: "40px 20px" };
const heading = { fontSize: "22px", fontWeight: 700, color: "#111827", margin: "0 0 16px" };
const paragraph = { fontSize: "16px", color: "#374151", lineHeight: 1.6, margin: "0 0 24px" };
const button = {
  backgroundColor: "#10b981",
  color: "#ffffff",
  padding: "12px 24px",
  borderRadius: "8px",
  textDecoration: "none",
  display: "inline-block",
  fontWeight: 600,
};
const divider = { borderColor: "#e5e7eb", margin: "32px 0 16px" };
const footer = { fontSize: "12px", color: "#6b7280" };

Two things worth pointing out:

  1. Styles are plain JS objects rendered as inline style attributes by React Email. Inline styles are non-negotiable: most clients strip <style> tags, and Gmail rewrites them in odd ways. Define style constants once, reuse across emails, ship inline.
  2. Props are typed. You'll thank yourself the first time a JSON column in your DB grows a new field and TypeScript fails the build instead of letting you ship Welcome, undefined.

3. Render to HTML

At send time you don't import React Email components in your hot path. You import the rendered string:

import { render } from "@react-email/render";
import WelcomeEmail from "../emails/welcome";

const html = await render(<WelcomeEmail name="Bobby" dashboardUrl="https://app.example.com" />);
const text = await render(<WelcomeEmail name="Bobby" dashboardUrl="https://app.example.com" />, {
  plainText: true,
});

render is async because it pretty-prints by default. The plainText: true option strips tags and gives you a clean text/plain alternative. Always include it on every send: it improves deliverability, and Apple Mail's preview pane reads it.

In production you can pre-render templates at build time if your props are static, or render at request time and rely on V8 to keep the work cheap (it's templated string concatenation, not React reconciliation).

4. Preview Locally

Install the CLI once:

npm install --save-dev react-email
npx react-email dev

Open http://localhost:3000. Every .tsx file under emails/ shows up as a route. Hot reload on save. Send-to-inbox button if you set RESEND_API_KEY (works fine pointed at SMTPfast too because the API is compatible).

This is the workflow that makes React Email worth using. Authoring inline-style table HTML is painful precisely because the feedback loop is slow. Local preview cuts the loop to a save.

5. Reuse Components

The win over hand-written templates is composition. A typical layout:

// emails/_components/layout.tsx
import { Html, Head, Body, Container, Img, Hr } from "@react-email/components";
import { ReactNode } from "react";

export function Layout({ preview, children }: { preview?: string; children: ReactNode }) {
  return (
    <Html>
      <Head>
        <meta name="color-scheme" content="light" />
        <meta name="supported-color-schemes" content="light" />
      </Head>
      {preview && <span style={{ display: "none" }}>{preview}</span>}
      <Body style={{ backgroundColor: "#f6f9fc", margin: 0 }}>
        <Container style={{ maxWidth: "600px", margin: "0 auto", padding: "32px 20px" }}>
          <Img src="https://cdn.example.com/logo.png" alt="Acme" width="120" />
          <Hr style={{ borderColor: "#e5e7eb", margin: "24px 0" }} />
          {children}
        </Container>
      </Body>
    </Html>
  );
}

Now every email is just the inner content:

import { Layout } from "./_components/layout";
import { Text, Button } from "@react-email/components";

export default function ResetPasswordEmail({ resetUrl }: { resetUrl: string }) {
  return (
    <Layout preview="Reset your password">
      <Text>We got a request to reset your password.</Text>
      <Button href={resetUrl}>Reset password</Button>
    </Layout>
  );
}

The hidden preview text is what Gmail shows next to the subject in the inbox list. Setting it explicitly stops Gmail from grabbing your first paragraph or, worse, an alt-text string from your logo.

6. Use Tailwind If You Want

If you already think in Tailwind, the <Tailwind> wrapper inlines class names into final styles at render time. No tailwind config needed in production:

import { Tailwind, Body, Container, Text, Button } from "@react-email/components";

export default function NotificationEmail() {
  return (
    <Tailwind>
      <Body className="bg-slate-100 font-sans">
        <Container className="mx-auto max-w-[600px] p-8">
          <Text className="text-2xl font-bold text-slate-900">New comment</Text>
          <Text className="text-slate-600 leading-relaxed">
            Someone replied to your post.
          </Text>
          <Button href="https://app.example.com/inbox" className="bg-emerald-500 text-white px-6 py-3 rounded-lg">
            View reply
          </Button>
        </Container>
      </Body>
    </Tailwind>
  );
}

Caveats: arbitrary Tailwind values like bg-[#abc123] work, but classes that depend on JIT discovery from the rest of your app won't. Pin to the standard palette and named utilities and you'll be fine.

7. Survive Outlook

Three things break in Outlook desktop. React Email handles two of them; you handle the third.

VML buttons. Outlook desktop ignores border-radius and padding on <a> tags. The <Button> component already wraps a <v:roundrect> fallback in mso conditional comments so the button renders correctly in both Outlook and everything else. You don't need to do anything; the button just works.

Container widths. Outlook's rendering engine doesn't honor max-width. Use a fixed width="600" attribute on the table-based <Container> and Outlook will respect it. React Email's <Container> does this for you.

Dark mode. Outlook on Windows ignores your colors and inverts the email. To opt out, the layout above already includes the meta tags. Add this to the <Head> for the same effect on iOS Mail:

<Head>
  <meta name="color-scheme" content="light" />
  <meta name="supported-color-schemes" content="light" />
  <style>{`
    :root { color-scheme: light; supported-color-schemes: light; }
    @media (prefers-color-scheme: dark) {
      .dark-mode-stay-light { background-color: #ffffff !important; color: #111827 !important; }
    }
  `}</style>
</Head>

Slap className="dark-mode-stay-light" on any element you want to keep its colors. This is the only <style> block we let through, and only because the @media (prefers-color-scheme: dark) query is one of the few things Apple Mail and modern Gmail respect.

8. Hand-Written, If You Refuse the Dependency

If you want zero dependencies, a tagged template literal does the job:

export function welcomeEmail({ name, dashboardUrl }: { name: string; dashboardUrl: string }): string {
  return `
    <div style="font-family: system-ui, -apple-system, sans-serif; max-width: 600px; margin: 0 auto; padding: 40px 20px;">
      <h1 style="font-size: 22px; color: #111827; margin: 0 0 16px;">Welcome, ${escapeHtml(name)}</h1>
      <p style="font-size: 16px; color: #374151; line-height: 1.6; margin: 0 0 24px;">
        Thanks for signing up. Click below to get started.
      </p>
      <a href="${escapeHtml(dashboardUrl)}"
         style="display: inline-block; background: #10b981; color: white;
                padding: 12px 24px; border-radius: 8px; text-decoration: none; font-weight: 600;">
        Open dashboard
      </a>
    </div>
  `;
}

function escapeHtml(s: string) {
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}

Two warnings if you go this route:

  • Always escape user-controlled values. The function above does it; the version you write at 4 PM on a Friday probably won't.
  • Plain text is on you. No render(..., { plainText: true }) to fall back on, so include a hand-written text body alongside the HTML when you call the API.

9. Email Template Rules

These hold whether you use React Email or string templates:

  • Inline styles only. Most email clients strip <style> tags. The dark-mode override above is a calculated exception.
  • Tables for layout, not flex or grid. Outlook falls back to a Word renderer that doesn't know what they are.
  • Cap content at 600px wide. Some clients cut off wider templates.
  • Always send a plain text part. Better deliverability, better preview, and required for proper accessibility.
  • Test in Litmus or Email on Acid before launch. Most things render fine in Gmail / Apple Mail / iOS but break in Outlook 2016.
  • Keep images lightweight. Gmail clips emails over 102 KB and folds the rest behind a "view entire message" link. Heavy hero images push you over the limit fast.

10. Send Through SMTPfast

Once your template is ready, the send is one fetch call:

import { render } from "@react-email/render";
import WelcomeEmail from "./emails/welcome";

const html = await render(<WelcomeEmail name={user.name} dashboardUrl={dashboardUrl} />);
const text = await render(<WelcomeEmail name={user.name} dashboardUrl={dashboardUrl} />, {
  plainText: true,
});

await fetch("https://smtpfa.st/api/v1/emails", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.SMTPFAST_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    from: "[email protected]",
    to: user.email,
    subject: "Welcome to Acme",
    html,
    text,
  }),
});

The API is Resend-compatible: if you already have a from / to / subject / html / text payload going somewhere else, just point the URL at https://smtpfa.st/api/v1/emails and swap the API key.

What This Buys You

You stop hand-writing inline styles. Templates become regular React, with props, types, components you reuse, and a local preview server. Renders to the same constrained HTML the email client demands, but you don't author against the constraint.

If your project already has React, the extra dependency is two packages totaling a few hundred kilobytes. The first time you reuse a header across five transactional emails it pays for itself.

Ready to get started?

Start sending transactional email today. Free to start, no credit card required.

Get Started for Free