Loading…
Loading…
Build a production-ready transactional email system using React Email for templating and Resend for delivery, with preview workflows, dynamic data, and error handling.
Tags
React Email lets you build email templates as React components with hot reload preview, and Resend handles reliable delivery -- replacing clunky HTML email builders with a modern developer experience.
Initialize a new project and install the required packages.
mkdir email-system && cd email-system
npm init -y
npm install resend @react-email/components react react-dom
npm install -D typescript @types/react @types/react-dom tsxCreate a tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}Set up the project structure:
email-system/
├── src/
│ ├── emails/ # React Email templates
│ ├── lib/ # Resend client and helpers
│ └── send.ts # Entry point for sending
├── tsconfig.json
└── package.json
Create a Resend client wrapper that handles initialization and error handling.
// src/lib/resend.ts
import { Resend } from "resend";
if (!process.env.RESEND_API_KEY) {
throw new Error("RESEND_API_KEY environment variable is required");
}
export const resend = new Resend(process.env.RESEND_API_KEY);
interface SendEmailOptions {
to: string | string[];
subject: string;
react: React.ReactElement;
from?: string;
}
export async function sendEmail({ to, subject, react, from }: SendEmailOptions) {
const { data, error } = await resend.emails.send({
from: from || "Your App <noreply@yourdomain.com>",
to: Array.isArray(to) ? to : [to],
subject,
react,
});
if (error) {
console.error("Failed to send email:", error);
throw new Error(`Email delivery failed: ${error.message}`);
}
return data;
}React Email components compile to cross-client compatible HTML. You write JSX, and it outputs HTML that works in Gmail, Outlook, and Apple Mail.
// src/emails/WelcomeEmail.tsx
import {
Html,
Head,
Body,
Container,
Section,
Text,
Button,
Heading,
Hr,
Preview,
Img,
} from "@react-email/components";
interface WelcomeEmailProps {
userName: string;
loginUrl: string;
}
export function WelcomeEmail({ userName, loginUrl }: WelcomeEmailProps) {
return (
<Html>
<Head />
<Preview>Welcome to our platform, {userName}!</Preview>
<Body style={main}>
<Container style={container}>
<Heading style={h1}>Welcome aboard, {userName}!</Heading>
<Text style={text}>
We are excited to have you on board. Your account has been created
successfully and you are ready to get started.
</Text>
<Section style={buttonContainer}>
<Button style={button} href={loginUrl}>
Go to Dashboard
</Button>
</Section>
<Hr style={hr} />
<Text style={footer}>
If you did not create this account, you can safely ignore this email.
</Text>
</Container>
</Body>
</Html>
);
}
// Inline styles are required for email compatibility
const main = {
backgroundColor: "#f6f9fc",
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
};
const container = {
backgroundColor: "#ffffff",
margin: "0 auto",
padding: "40px 20px",
maxWidth: "560px",
borderRadius: "8px",
};
const h1 = {
color: "#1a1a1a",
fontSize: "24px",
fontWeight: "bold" as const,
margin: "0 0 16px",
};
const text = {
color: "#4a4a4a",
fontSize: "16px",
lineHeight: "26px",
margin: "0 0 16px",
};
const buttonContainer = {
textAlign: "center" as const,
margin: "24px 0",
};
const button = {
backgroundColor: "#2563eb",
borderRadius: "6px",
color: "#fff",
fontSize: "16px",
fontWeight: "bold" as const,
textDecoration: "none",
textAlign: "center" as const,
padding: "12px 24px",
};
const hr = {
borderColor: "#e6e6e6",
margin: "24px 0",
};
const footer = {
color: "#8c8c8c",
fontSize: "12px",
};
export default WelcomeEmail;Email clients strip <style> tags and ignore external stylesheets. React Email handles this by inlining styles into each element during compilation, so your styles actually reach the recipient.
Transactional emails often include dynamic data like line items. Here is an invoice template that maps over an array of items.
// src/emails/InvoiceEmail.tsx
import {
Html,
Head,
Body,
Container,
Section,
Text,
Heading,
Hr,
Preview,
Row,
Column,
} from "@react-email/components";
interface LineItem {
description: string;
quantity: number;
unitPrice: number;
}
interface InvoiceEmailProps {
customerName: string;
invoiceNumber: string;
invoiceDate: string;
dueDate: string;
items: LineItem[];
currency?: string;
}
export function InvoiceEmail({
customerName,
invoiceNumber,
invoiceDate,
dueDate,
items,
currency = "USD",
}: InvoiceEmailProps) {
const total = items.reduce((sum, item) => sum + item.quantity * item.unitPrice, 0);
const formatCurrency = (amount: number) =>
new Intl.NumberFormat("en-US", { style: "currency", currency }).format(amount);
return (
<Html>
<Head />
<Preview>Invoice #{invoiceNumber} - {formatCurrency(total)}</Preview>
<Body style={main}>
<Container style={container}>
<Heading style={h1}>Invoice #{invoiceNumber}</Heading>
<Text style={text}>Hi {customerName},</Text>
<Text style={text}>
Here is your invoice for the services rendered. Payment is due by {dueDate}.
</Text>
<Section style={metaSection}>
<Row>
<Column><Text style={metaLabel}>Invoice Date</Text></Column>
<Column><Text style={metaValue}>{invoiceDate}</Text></Column>
</Row>
<Row>
<Column><Text style={metaLabel}>Due Date</Text></Column>
<Column><Text style={metaValue}>{dueDate}</Text></Column>
</Row>
</Section>
<Hr style={hr} />
{/* Table header */}
<Section>
<Row>
<Column style={tableHeader}>Description</Column>
<Column style={{ ...tableHeader, textAlign: "center" as const }}>Qty</Column>
<Column style={{ ...tableHeader, textAlign: "right" as const }}>Amount</Column>
</Row>
</Section>
{/* Line items */}
{items.map((item, index) => (
<Section key={index}>
<Row>
<Column style={tableCell}>{item.description}</Column>
<Column style={{ ...tableCell, textAlign: "center" as const }}>
{item.quantity}
</Column>
<Column style={{ ...tableCell, textAlign: "right" as const }}>
{formatCurrency(item.quantity * item.unitPrice)}
</Column>
</Row>
</Section>
))}
<Hr style={hr} />
<Section>
<Row>
<Column />
<Column style={totalLabel}>Total</Column>
<Column style={totalValue}>{formatCurrency(total)}</Column>
</Row>
</Section>
<Hr style={hr} />
<Text style={footer}>
Questions about this invoice? Reply to this email or contact support.
</Text>
</Container>
</Body>
</Html>
);
}
const main = {
backgroundColor: "#f6f9fc",
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
};
const container = {
backgroundColor: "#ffffff",
margin: "0 auto",
padding: "40px 20px",
maxWidth: "600px",
borderRadius: "8px",
};
const h1 = { color: "#1a1a1a", fontSize: "24px", fontWeight: "bold" as const };
const text = { color: "#4a4a4a", fontSize: "16px", lineHeight: "26px" };
const metaSection = { margin: "16px 0" };
const metaLabel = { color: "#8c8c8c", fontSize: "14px", margin: "4px 0" };
const metaValue = { color: "#1a1a1a", fontSize: "14px", margin: "4px 0", textAlign: "right" as const };
const hr = { borderColor: "#e6e6e6", margin: "24px 0" };
const tableHeader = { color: "#8c8c8c", fontSize: "12px", fontWeight: "bold" as const, padding: "8px 0", textTransform: "uppercase" as const };
const tableCell = { color: "#1a1a1a", fontSize: "14px", padding: "8px 0" };
const totalLabel = { color: "#1a1a1a", fontSize: "16px", fontWeight: "bold" as const, textAlign: "right" as const, padding: "8px 16px 8px 0" };
const totalValue = { color: "#1a1a1a", fontSize: "16px", fontWeight: "bold" as const, textAlign: "right" as const, padding: "8px 0" };
const footer = { color: "#8c8c8c", fontSize: "12px" };
export default InvoiceEmail;React Email includes a development server that renders your templates in the browser with hot reload.
npm install -D react-emailAdd the preview script to package.json:
{
"scripts": {
"email:dev": "email dev --dir src/emails --port 3001",
"email:export": "email export --dir src/emails --outDir dist/emails"
}
}Run npm run email:dev and open http://localhost:3001 in your browser. You will see a sidebar listing all your email templates. Click any template to preview it, and changes to the source files will hot reload in the browser.
Create a .react-email/ config or export default props directly from your templates:
// At the bottom of WelcomeEmail.tsx
WelcomeEmail.PreviewProps = {
userName: "John Doe",
loginUrl: "https://app.example.com/dashboard",
} satisfies WelcomeEmailProps;The preview server automatically uses PreviewProps to render the template with sample data.
Create a service layer that ties templates to delivery.
// src/lib/emailService.ts
import { sendEmail } from "./resend";
import { WelcomeEmail } from "../emails/WelcomeEmail";
import { InvoiceEmail } from "../emails/InvoiceEmail";
import type { ReactElement } from "react";
export const emailService = {
async sendWelcome(to: string, userName: string) {
return sendEmail({
to,
subject: `Welcome to our platform, ${userName}!`,
react: WelcomeEmail({ userName, loginUrl: "https://app.example.com/dashboard" }) as ReactElement,
});
},
async sendInvoice(
to: string,
invoice: {
customerName: string;
invoiceNumber: string;
invoiceDate: string;
dueDate: string;
items: { description: string; quantity: number; unitPrice: number }[];
}
) {
const total = invoice.items.reduce((s, i) => s + i.quantity * i.unitPrice, 0);
return sendEmail({
to,
subject: `Invoice #${invoice.invoiceNumber} - $${total.toFixed(2)}`,
react: InvoiceEmail(invoice) as ReactElement,
});
},
};If you are using Next.js, call the service from a route handler:
// app/api/auth/register/route.ts
import { emailService } from "@/lib/emailService";
import { NextResponse } from "next/server";
export async function POST(req: Request) {
const { email, name } = await req.json();
// ... create user in database ...
await emailService.sendWelcome(email, name);
return NextResponse.json({ message: "User created" });
}Production email systems need retry logic and error tracking.
// src/lib/resilientSend.ts
import { sendEmail } from "./resend";
interface RetryOptions {
maxRetries: number;
baseDelay: number;
}
export async function sendWithRetry(
options: Parameters<typeof sendEmail>[0],
retryConfig: RetryOptions = { maxRetries: 3, baseDelay: 1000 }
) {
let lastError: Error | null = null;
for (let attempt = 0; attempt <= retryConfig.maxRetries; attempt++) {
try {
const result = await sendEmail(options);
return result;
} catch (error) {
lastError = error as Error;
if (attempt < retryConfig.maxRetries) {
const delay = retryConfig.baseDelay * Math.pow(2, attempt);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
}
throw new Error(
`Failed to send email after ${retryConfig.maxRetries + 1} attempts: ${lastError?.message}`
);
}Your email system now has three layers:
src/emails/ that define layout and accept typed propssrc/lib/ that handles delivery, retries, and errorsThe development workflow is straightforward: edit templates with the preview server running, see changes instantly in the browser, and deploy the same components that get compiled to production HTML.
resend.batch.send() for sending to multiple recipients efficientlyReact Email is an open-source library that lets you build email templates using React components. It compiles JSX into cross-client compatible HTML, so you get component reusability and a local preview server while producing emails that render correctly in Gmail, Outlook, and Apple Mail.
Resend offers a developer-first API with excellent TypeScript support, built-in React Email integration, and simpler pricing. It handles deliverability, bounce tracking, and analytics with a cleaner DX than legacy providers like SendGrid.
React Email includes a local dev server that renders your templates in the browser with hot reload. You can pass sample props to preview different states, and Resend provides a test mode that logs emails without sending them in development.
Collaboration
I help startups and established companies design, build, and scale world-class digital products. From deep technical architecture to pixel-perfect UI: let's bring your vision to life.
Learn how to instrument a Node.js app with OpenTelemetry for traces, metrics, and logs, and build a practical observability setup for production debugging.
A practical guide to building a Backend-for-Frontend with Next.js and Node.js for API aggregation, auth handling, caching, and frontend-specific data shaping.
A practical CI/CD blueprint for Next.js apps using Docker and GitHub Actions, including testing, image builds, deployment stages, cache strategy, and release safety.