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.
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.
Concept mapping: Handlebars to React Email
Every Handlebars pattern has a direct React Email equivalent. Here's the translation table:
- {{variable}} — string interpolation
- {{#if condition}} — conditional blocks
- {{#each items}} — iteration
- {{> partial}} — partial includes
- {{helper arg}} — custom helpers
- layouts/main.hbs — layout inheritance
- {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)
<h1>Welcome, {{userName}}!</h1>
<p>Your account ({{email}}) is ready.</p>
{{! Triple braces for unescaped HTML — dangerous }}
{{{customHtml}}}After (React Email)
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>
);
}{{{) 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
{{#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
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>
);
}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.
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
<a href="{{url}}" style="background-color: {{color}}; padding: 12px 24px;
color: #fff; text-decoration: none; border-radius: 6px;">
{{label}}
</a>{{> button url=loginUrl color="#22c55e" label="Get Started"}}After
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>
);
}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.
// 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");
}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.
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:
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:
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.
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.
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.
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.
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.
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 });
}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
- Install React Email and set up the dev preview server
- Create base layout component (replaces your Handlebars layout)
- Move helpers to typed utility functions
- Migrate partials to React components
- Build the send abstraction for coexistence
- Migrate templates one at a time (most-changed first)
- Compare rendered HTML output for each template
- Test in major email clients (Gmail, Outlook, Apple Mail)
- Delete old Handlebars templates after production validation
- 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.