React Email12 min read

Migrating from Handlebars to React Email: A Practical Guide

A file-by-file migration playbook: map Handlebars partials, helpers, and layouts to React Email components. Includes a coexistence pattern so you can migrate one template at a time without downtime.

R

React Emails Pro

March 6, 2026

Your Handlebars email templates work. They've worked for years. But every time you touch them, something breaks: a missing helper, a conditional that renders wrong in Outlook, a partial that nobody remembers writing. The migration isn't about hating Handlebars. It's about wanting type safety, component reuse, and a dev experience that doesn't require archaeology.

This guide is the practical playbook for moving a Handlebars email codebase to React Email without rewriting everything at once.

This post covers: mapping Handlebars concepts to React Email equivalents, a file-by-file migration strategy, handling partials/helpers/layouts, and avoiding the traps that make migrations stall.

Why teams migrate (and why they stall)

The reasons to move are usually obvious:

  • No type checking — typos in variable names silently render empty
  • Partials are stringly-typed and break without warning
  • Testing requires rendering full HTML and eyeballing it
  • No IDE support — no autocomplete, no jump-to-definition
  • CSS inlining is a separate build step that drifts from your templates

The reason migrations stall is also obvious: you have 15+ templates in production, they work, and rewriting them all at once is a multi-week project nobody wants to sign up for.

Don't rewrite everything at once. Migrate one template at a time, starting with the one that changes most often. Ship it. Then do the next one. The two systems can coexist for months.

Concept mapping: Handlebars to React Email

Every Handlebars pattern has a direct React Email equivalent. Here's the translation table:

Handlebars
  • {{variable}} — string interpolation
  • {{#if condition}} — conditional blocks
  • {{#each items}} — iteration
  • {{> partial}} — partial includes
  • {{helper arg}} — custom helpers
  • layouts/main.hbs — layout inheritance
React Email
  • {variable} — JSX expression
  • {condition && <JSX>} — conditional rendering
  • {items.map(item => <JSX>)} — Array.map()
  • <PartialComponent /> — React component
  • Regular functions or hooks
  • <BaseLayout>{children}</BaseLayout> — composition

Variables and expressions

Handlebars uses double curly braces and escapes HTML by default. React Email uses JSX expressions and also escapes by default.

Before (Handlebars)

templates/welcome.hbs
<h1>Welcome, {{userName}}!</h1>
<p>Your account ({{email}}) is ready.</p>

{{! Triple braces for unescaped HTML — dangerous }}
{{{customHtml}}}

After (React Email)

emails/welcome.tsx
type WelcomeProps = {
  userName: string;
  email: string;
};

export default function WelcomeEmail({ userName, email }: WelcomeProps) {
  return (
    <BaseLayout previewText={`Welcome, ${userName}!`}>
      <Heading>Welcome, {userName}!</Heading>
      <Text>Your account ({email}) is ready.</Text>
    </BaseLayout>
  );
}
If your Handlebars templates use triple braces ({{{) for raw HTML injection, stop and think. In React Email, use dangerouslySetInnerHTML only as a last resort. Most cases are better solved with proper components.

Conditionals

Handlebars conditionals are block-based. React uses inline expressions. The React version is more flexible but requires you to handle falsy values explicitly.

Before

templates/order.hbs
{{#if discount}}
  <p>Discount applied: {{discount.code}} (-{{discount.amount}})</p>
{{else}}
  <p>No discount applied.</p>
{{/if}}

{{#unless isVerified}}
  <p>Please verify your email.</p>
{{/unless}}

After

emails/order.tsx
type OrderProps = {
  discount?: { code: string; amount: string };
  isVerified: boolean;
};

export default function OrderEmail({ discount, isVerified }: OrderProps) {
  return (
    <BaseLayout previewText="Your order confirmation">
      {discount ? (
        <Text>Discount applied: {discount.code} (-{discount.amount})</Text>
      ) : (
        <Text>No discount applied.</Text>
      )}

      {!isVerified && <Text>Please verify your email.</Text>}
    </BaseLayout>
  );
}
Key takeaway

TypeScript catches the bugs Handlebars can't. If discount is typed as optional, the compiler forces you to handle both cases. In Handlebars, a missing variable silently renders nothing.


Loops and iteration

Handlebars {{#each}} maps directly to Array.map(). The main difference: React requires a key prop for list items.

emails/invoice.tsx
type LineItem = { description: string; amount: number };

function LineItems({ items }: { items: LineItem[] }) {
  return (
    <Section>
      {items.map((item, index) => (
        <Row key={index} style={{ borderBottom: "1px solid #e5e7eb" }}>
          <Column><Text>{item.description}</Text></Column>
          <Column style={{ textAlign: "right" }}>
            <Text>{formatCurrency(item.amount)}</Text>
          </Column>
        </Row>
      ))}
    </Section>
  );
}

Partials become components

This is where the migration pays for itself. Handlebars partials are string includes with no type safety. React components are typed, composable, and refactorable with IDE support.

Before

partials/button.hbs
<a href="{{url}}" style="background-color: {{color}}; padding: 12px 24px;
  color: #fff; text-decoration: none; border-radius: 6px;">
  {{label}}
</a>
templates/welcome.hbs
{{> button url=loginUrl color="#22c55e" label="Get Started"}}

After

emails/components/email-button.tsx
import { Button } from "@react-email/components";

type EmailButtonProps = {
  href: string;
  color?: string;
  children: React.ReactNode;
};

export function EmailButton({
  href,
  color = "#22c55e",
  children,
}: EmailButtonProps) {
  return (
    <Button
      href={href}
      style={{
        backgroundColor: color,
        padding: "12px 24px",
        color: "#fff",
        textDecoration: "none",
        borderRadius: "6px",
        fontWeight: 600,
      }}
    >
      {children}
    </Button>
  );
}
emails/welcome.tsx
import { EmailButton } from "./components/email-button";

// Type-safe, auto-complete, click-to-navigate
<EmailButton href={loginUrl}>Get Started</EmailButton>

Helpers become functions

Handlebars helpers are globally registered functions. In React Email, they're just TypeScript functions you import.

emails/utils/format.ts
// Replaces: Handlebars.registerHelper('formatDate', ...)
export function formatDate(date: Date | string, locale = "en-US"): string {
  return new Intl.DateTimeFormat(locale, {
    year: "numeric",
    month: "long",
    day: "numeric",
  }).format(new Date(date));
}

// Replaces: Handlebars.registerHelper('formatCurrency', ...)
export function formatCurrency(
  amount: number,
  currency = "USD"
): string {
  return new Intl.NumberFormat("en-US", {
    style: "currency",
    currency,
  }).format(amount);
}

// Replaces: Handlebars.registerHelper('pluralize', ...)
export function pluralize(
  count: number,
  singular: string,
  plural?: string
): string {
  return count === 1 ? singular : (plural ?? singular + "s");
}
Move your Handlebars helper implementations into a utils/ folder first. Most helpers translate 1:1 — you're just removing the Handlebars registration wrapper and adding types.

Layouts become composition

Handlebars uses layout inheritance with {{> @partial-block}} or a layout engine. React Email uses component composition — a children prop.

emails/layouts/base-layout.tsx
import {
  Html, Head, Preview, Body, Container,
  Section, Img, Text, Hr,
} from "@react-email/components";

type BaseLayoutProps = {
  previewText: string;
  children: React.ReactNode;
};

export function BaseLayout({ previewText, children }: BaseLayoutProps) {
  return (
    <Html>
      <Head />
      <Preview>{previewText}</Preview>
      <Body style={{ backgroundColor: "#f6f9fc", fontFamily: "sans-serif" }}>
        <Container style={{ maxWidth: "560px", margin: "0 auto" }}>
          <Section style={{ textAlign: "center", padding: "24px 0" }}>
            <Img src="https://yourapp.com/logo.png" width={120} alt="Logo" />
          </Section>
          {children}
          <Hr style={{ borderColor: "#e5e7eb", margin: "32px 0" }} />
          <Text style={{ fontSize: "13px", color: "#6b7280", textAlign: "center" }}>
            Your Company · 123 Main St · City, ST 12345
          </Text>
        </Container>
      </Body>
    </Html>
  );
}

If you had multiple Handlebars layouts (e.g., transactional.hbs and marketing.hbs), create separate layout components that share a common base:

emails/layouts/marketing-layout.tsx
import { BaseLayout } from "./base-layout";
import { Section, Text, Link } from "@react-email/components";

type MarketingLayoutProps = {
  previewText: string;
  children: React.ReactNode;
  unsubscribeUrl: string;
};

export function MarketingLayout({
  previewText,
  children,
  unsubscribeUrl,
}: MarketingLayoutProps) {
  return (
    <BaseLayout previewText={previewText}>
      {children}
      <Section style={{ textAlign: "center", padding: "16px 0" }}>
        <Link href={unsubscribeUrl} style={{ fontSize: "13px", color: "#6b7280" }}>
          Unsubscribe
        </Link>
      </Section>
    </BaseLayout>
  );
}

Migration strategy: one template at a time

The mistake teams make is trying to migrate everything in one PR. Here's the approach that actually ships:

1

Set up React Email alongside Handlebars

Install React Email, create the emails/ directory, and set up your base layout. Both systems coexist — your send function picks the right renderer based on template type.

2

Create a send abstraction

Wrap your email sending in a function that can render either Handlebars or React Email. This lets you migrate templates individually without changing calling code.

3

Migrate the most-changed template first

Pick the template your team edits most often. Migrate it, ship it, and validate in production. This builds confidence and reveals any rendering differences early.

4

Compare output HTML

For each migrated template, render both versions with the same data and diff the HTML. You're looking for visual differences, not exact matches — React Email will produce cleaner HTML.

5

Delete the Handlebars version

Once the React Email version is in production and validated, delete the old template. Don't keep both around “just in case.”


The send abstraction (coexistence pattern)

This is the key to a non-disruptive migration. Your application code calls one function; the function decides which renderer to use.

lib/email/send.ts
import { render } from "@react-email/render";
import Handlebars from "handlebars";
import fs from "fs";

type EmailPayload = {
  to: string;
  subject: string;
  template: string;
  data: Record<string, unknown>;
};

// Registry of migrated templates (React Email components)
const REACT_TEMPLATES: Record<string, React.ComponentType<any>> = {
  welcome: require("@/emails/welcome").default,
  "password-reset": require("@/emails/password-reset").default,
  // Add templates here as you migrate them
};

export async function sendEmail({ to, subject, template, data }: EmailPayload) {
  let html: string;

  if (REACT_TEMPLATES[template]) {
    // Use React Email (migrated)
    const Component = REACT_TEMPLATES[template];
    html = await render(<Component {...data} />);
  } else {
    // Fall back to Handlebars (not yet migrated)
    const source = fs.readFileSync(
      `templates/${template}.hbs`,
      "utf-8"
    );
    html = Handlebars.compile(source)(data);
  }

  await emailProvider.send({ to, subject, html });
}
This pattern means zero changes to your API routes, server actions, or background jobs. The migration is invisible to calling code.

Common migration traps

  • Outlook rendering differences: React Email handles Outlook quirks better than raw Handlebars, but test in Litmus or Email on Acid after migration. Some spacing will change.
  • Missing data handling: Handlebars silently renders empty strings for missing variables. TypeScript will catch these at compile time — which is the point, but expect a burst of type errors during migration.
  • CSS inlining: If you were using a separate CSS inlining step (like Juice), React Email handles this internally. Remove your old inlining pipeline for migrated templates.
  • Preview data: Handlebars previews often used JSON fixture files. Move these to typed factory functions so they stay in sync with your prop types.

Migration checklist

  1. Install React Email and set up the dev preview server
  2. Create base layout component (replaces your Handlebars layout)
  3. Move helpers to typed utility functions
  4. Migrate partials to React components
  5. Build the send abstraction for coexistence
  6. Migrate templates one at a time (most-changed first)
  7. Compare rendered HTML output for each template
  8. Test in major email clients (Gmail, Outlook, Apple Mail)
  9. Delete old Handlebars templates after production validation
  10. Remove Handlebars dependency once all templates are migrated

The migration isn't glamorous. It's a template-by-template grind over weeks. But each one you move gives you type safety, better previews, and one less thing that breaks silently. That compounds fast.

R

React Emails Pro

Team

Building production-ready email templates with React Email. Writing about transactional email best practices, deliverability, and developer tooling.

Production-ready templates

Pick from 9 template packs built with React Email. One-time purchase, lifetime updates, tested across every major email client.

Browse all templates