Build a Transactional Email System with React Email and Resend
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
Build a Transactional Email System with React Email and Resend
TL;DR
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.
Prerequisites
- ›Node.js 18+ installed
- ›A Resend account (free tier available at resend.com)
- ›Basic React and TypeScript knowledge
- ›A verified domain or Resend's sandbox for testing
Step 1: Project Setup
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
Step 2: Configure the Resend Client
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;
}Step 3: Build the Welcome Email Template
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;Why Inline Styles?
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.
Step 4: Build an Invoice Email Template
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;Step 5: Set Up the Preview Server
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.
Passing Preview Props
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.
Step 6: Send Emails from Your Application
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,
});
},
};Usage in an API Route
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" });
}Step 7: Handle Errors and Retries
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}`
);
}Putting It All Together
Your email system now has three layers:
- ›Templates -- React Email components in
src/emails/that define layout and accept typed props - ›Client -- Resend SDK wrapper in
src/lib/that handles delivery, retries, and errors - ›Service -- Business logic layer that maps application events (user signup, invoice created) to the correct template and recipient
The 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.
Next Steps
- ›Add a password reset email -- create a template with a time-limited token link
- ›Set up webhooks -- use Resend webhooks to track delivery, bounces, and opens
- ›Batch sending -- use
resend.batch.send()for sending to multiple recipients efficiently - ›Add attachments -- Resend supports file attachments for invoice PDFs
- ›Internationalization -- create locale-specific template variants or use i18n interpolation in templates
FAQ
What is React Email?
React 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.
Why use Resend instead of SendGrid or SES?
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.
How do you test email templates locally?
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
Need help with a project?
Let's Build It
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.
Related Articles
How to Add Observability to a Node.js App with OpenTelemetry
Learn how to instrument a Node.js app with OpenTelemetry for traces, metrics, and logs, and build a practical observability setup for production debugging.
How to Build a Backend-for-Frontend (BFF) with Next.js and Node.js
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.
How I Structure CI/CD for Next.js, Docker, and GitHub Actions
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.