React Email Templates: Building Transactional Emails
Transactional email is the most under-designed part of most web applications. Welcome emails, purchase confirmations, password resets, and license activations are often templated with string concatenation, hardcoded HTML tables, and no testing — until a client reports that the email renders as gibberish in Outlook 2019. The result is a first impression (welcome email) or a critical business touchpoint (invoice) that looks broken for a significant percentage of your users.
React Email changes this. Write email templates in React with TypeScript, preview them in a browser, and render them to battle-tested HTML that works in Gmail, Outlook, Apple Mail, and 20+ other clients. This guide covers the complete workflow from project setup through NestJS integration and production monitoring.
Key Takeaways
- React Email renders to static HTML using
@react-email/render— no client-side JavaScript in email- Export
renderfunctions from your templates, not raw components — avoids JSX compilation issues in NestJS- Use
@react-email/componentsprimitives (Html, Head, Body, Container, Section, Text, Button) — never raw HTML tags- Test in the React Email preview server during development; test in real clients before launch
- Send via Resend, AWS SES, or Nodemailer — React Email is transport-agnostic
- Email sends must be non-blocking (
.catch()) — never let email failure fail the primary operation- Inline all CSS — email clients do not support external stylesheets or most CSS features
- Never use
flexbox,grid, CSS variables, orcalc()in email CSS
Project Setup
pnpm add react-email @react-email/components @react-email/render
pnpm add -D @react-email/tailwind
Monorepo structure:
packages/
email-templates/
package.json
tsconfig.json
src/
welcome.tsx
purchase-confirmation.tsx
license-activation.tsx
invoice-email.tsx
index.ts # Re-exports render functions
components/
email-header.tsx
email-footer.tsx
email-button.tsx
{
"name": "@ecosire/email-templates",
"version": "1.0.0",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc",
"dev": "email dev --dir src --port 3003"
}
}
Core Layout Components
Email Header
// src/components/email-header.tsx
import { Img, Section, Text } from '@react-email/components';
interface EmailHeaderProps {
logoUrl: string;
previewText?: string;
}
export function EmailHeader({ logoUrl, previewText }: EmailHeaderProps) {
return (
<>
{previewText && (
<Text
style={{
display: 'none',
overflow: 'hidden',
maxHeight: 0,
maxWidth: 0,
opacity: 0,
}}
>
{previewText}
</Text>
)}
<Section
style={{
backgroundColor: '#0f172a',
padding: '24px 40px',
textAlign: 'center',
}}
>
<Img
src={logoUrl}
width="140"
height="32"
alt="ECOSIRE"
style={{ display: 'block', margin: '0 auto' }}
/>
</Section>
</>
);
}
Email Button
// src/components/email-button.tsx
import { Button } from '@react-email/components';
interface EmailButtonProps {
href: string;
children: React.ReactNode;
variant?: 'primary' | 'secondary';
}
export function EmailButton({ href, children, variant = 'primary' }: EmailButtonProps) {
const styles = {
primary: { backgroundColor: '#f59e0b', color: '#000000' },
secondary: { backgroundColor: '#1e293b', color: '#ffffff' },
};
return (
<Button
href={href}
style={{
...styles[variant],
borderRadius: '6px',
fontSize: '14px',
fontWeight: '600',
padding: '12px 24px',
textDecoration: 'none',
display: 'inline-block',
}}
>
{children}
</Button>
);
}
Welcome Email Template
// src/welcome.tsx
import {
Body, Container, Head, Heading,
Html, Hr, Preview, Section, Text,
} from '@react-email/components';
import { render } from '@react-email/render';
import { EmailHeader } from './components/email-header';
import { EmailButton } from './components/email-button';
import { EmailFooter } from './components/email-footer';
interface WelcomeEmailProps {
name: string;
email: string;
loginUrl: string;
}
function WelcomeEmail({ name, email, loginUrl }: WelcomeEmailProps) {
return (
<Html lang="en">
<Head />
<Preview>Welcome to ECOSIRE — your account is ready</Preview>
<Body style={{ fontFamily: 'sans-serif', backgroundColor: '#f8fafc' }}>
<Container style={{ maxWidth: '600px', margin: '40px auto', backgroundColor: '#fff' }}>
<EmailHeader
logoUrl="https://ecosire.com/logo-light.png"
previewText={`Welcome ${name}! Your ECOSIRE account is ready.`}
/>
<Section style={{ padding: '40px' }}>
<Heading style={{ fontSize: '24px', color: '#0f172a', margin: '0 0 16px' }}>
Welcome to ECOSIRE, {name}!
</Heading>
<Text style={{ color: '#475569', fontSize: '16px', lineHeight: '24px' }}>
Your account has been created with <strong>{email}</strong>. You now have
access to our full suite of Odoo ERP modules and enterprise services.
</Text>
<Section style={{ textAlign: 'center', margin: '32px 0' }}>
<EmailButton href={loginUrl}>Sign in to your account</EmailButton>
</Section>
<Hr style={{ borderColor: '#e2e8f0', margin: '32px 0' }} />
<Text style={{ color: '#94a3b8', fontSize: '14px' }}>
If you did not create this account, please ignore this email or
contact support at [email protected].
</Text>
</Section>
<EmailFooter />
</Container>
</Body>
</Html>
);
}
// Export render function — not the component
// This avoids JSX compilation issues when consumed from NestJS
export async function renderWelcomeEmail(props: WelcomeEmailProps): Promise<string> {
return render(<WelcomeEmail {...props} />);
}
Purchase Confirmation Template
// src/purchase-confirmation.tsx
import {
Body, Column, Container, Head, Heading,
Hr, Html, Preview, Row, Section, Text,
} from '@react-email/components';
import { render } from '@react-email/render';
import { EmailHeader } from './components/email-header';
import { EmailButton } from './components/email-button';
import { EmailFooter } from './components/email-footer';
interface LineItem {
name: string;
quantity: number;
price: number;
}
interface PurchaseConfirmationProps {
customerName: string;
orderNumber: string;
items: LineItem[];
total: number;
currency: string;
downloadUrl: string;
}
function PurchaseConfirmationEmail(props: PurchaseConfirmationProps) {
const { customerName, orderNumber, items, total, currency, downloadUrl } = props;
const fmt = new Intl.NumberFormat('en-US', { style: 'currency', currency });
return (
<Html lang="en">
<Head />
<Preview>Order #{orderNumber} confirmed — thank you for your purchase</Preview>
<Body style={{ fontFamily: 'sans-serif', backgroundColor: '#f8fafc' }}>
<Container style={{ maxWidth: '600px', margin: '40px auto', backgroundColor: '#fff' }}>
<EmailHeader logoUrl="https://ecosire.com/logo-light.png" />
<Section style={{ padding: '40px' }}>
<Heading style={{ fontSize: '22px', color: '#0f172a', margin: '0 0 8px' }}>
Order Confirmed
</Heading>
<Text style={{ color: '#64748b', margin: '0 0 32px' }}>
Hi {customerName}, order <strong>#{orderNumber}</strong> has been confirmed.
</Text>
<Section style={{ backgroundColor: '#f8fafc', borderRadius: '8px', padding: '16px' }}>
{items.map((item, i) => (
<Row key={i} style={{ marginBottom: '12px' }}>
<Column style={{ width: '70%' }}>
<Text style={{ margin: 0, fontWeight: '600', color: '#0f172a' }}>
{item.name}
</Text>
<Text style={{ margin: 0, color: '#64748b', fontSize: '14px' }}>
Qty: {item.quantity}
</Text>
</Column>
<Column style={{ textAlign: 'right' }}>
<Text style={{ margin: 0, fontWeight: '600' }}>
{fmt.format(item.price)}
</Text>
</Column>
</Row>
))}
<Hr style={{ borderColor: '#e2e8f0' }} />
<Row>
<Column style={{ width: '70%' }}>
<Text style={{ fontWeight: '700', margin: 0 }}>Total</Text>
</Column>
<Column style={{ textAlign: 'right' }}>
<Text style={{ fontWeight: '700', margin: 0, color: '#f59e0b' }}>
{fmt.format(total)}
</Text>
</Column>
</Row>
</Section>
<Section style={{ textAlign: 'center', margin: '32px 0' }}>
<EmailButton href={downloadUrl}>Download Your Files</EmailButton>
</Section>
</Section>
<EmailFooter />
</Container>
</Body>
</Html>
);
}
export async function renderPurchaseConfirmationEmail(
props: PurchaseConfirmationProps
): Promise<string> {
return render(<PurchaseConfirmationEmail {...props} />);
}
NestJS Email Module Integration
// apps/api/src/modules/email/email.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as nodemailer from 'nodemailer';
import {
renderWelcomeEmail,
renderPurchaseConfirmationEmail,
} from '@ecosire/email-templates';
@Injectable()
export class EmailService {
private readonly logger = new Logger(EmailService.name);
private readonly transporter: nodemailer.Transporter;
constructor(private config: ConfigService) {
this.transporter = nodemailer.createTransport({
host: config.get('SMTP_HOST'),
port: config.get<number>('SMTP_PORT', 587),
secure: config.get('NODE_ENV') === 'production',
auth: {
user: config.get('SMTP_USER'),
pass: config.get('SMTP_PASS'),
},
});
}
async sendWelcomeEmail(to: string, name: string): Promise<void> {
const html = await renderWelcomeEmail({
name,
email: to,
loginUrl: `${this.config.get('APP_URL')}/auth/login`,
});
await this.send({ to, subject: `Welcome to ECOSIRE, ${name}!`, html });
}
private async send(opts: {
to: string;
subject: string;
html: string;
attachments?: nodemailer.Attachment[];
}): Promise<void> {
try {
await this.transporter.sendMail({
from: `"ECOSIRE" <${this.config.get('SMTP_FROM')}>`,
...opts,
});
this.logger.log(`Email sent to ${opts.to}: ${opts.subject}`);
} catch (error) {
this.logger.error(`Email failed to ${opts.to}: ${(error as Error).message}`);
// Do not rethrow — email failure must not fail the primary operation
}
}
}
Non-blocking usage pattern in other services:
// In user.service.ts after creating a user
this.emailService.sendWelcomeEmail(user.email, user.name).catch((err) =>
this.logger.warn(`Welcome email failed for ${user.email}: ${err.message}`)
);
Email CSS Constraints
Email CSS is drastically more limited than browser CSS. Key rules:
Safe (works in all major clients):
padding, margin, font-family, font-size, line-height, color
background-color, border, width, max-width, text-align
display: block | inline | inline-block | table | table-cell
Avoid:
display: flex / grid — not supported in Outlook
CSS variables — not supported anywhere in email
calc() — fails in Outlook 2013-2019
position: fixed — ignored
border-radius — works everywhere except Outlook
@media queries — supported in modern clients but not Outlook
Use @react-email/tailwind for simpler templates:
import { Tailwind } from '@react-email/tailwind';
export function SimpleEmail() {
return (
<Tailwind>
<Section className="bg-slate-50 p-10">
<Heading className="text-2xl font-bold text-slate-900">
Hello World
</Heading>
</Section>
</Tailwind>
);
}
Preview Server and Cross-Client Testing
# Start the React Email preview server
cd packages/email-templates
pnpm dev
# Opens http://localhost:3003 with all templates listed
For cross-client testing before launch:
- Mailtrap (free) — email sandbox with inbox preview for 10+ clients
- Email on Acid or Litmus (paid) — 90+ client previews with screenshots
- Mail Tester — spam score analysis and deliverability check
Common rendering differences to test:
| Client | Key Issues |
|---|---|
| Outlook 2016-2021 | No flexbox, no border-radius, Word rendering engine |
| Gmail (web) | Strips <style> tags, clips emails >102KB |
| Apple Mail | Good rendering, but dark mode inverts colors |
| Gmail (iOS) | Generally good, watches for minimum font sizes |
| Samsung Mail | Older WebKit renderer, test carefully |
Invoice Email with PDF Attachment
// Attach a PDF-kit-generated invoice buffer
async sendInvoiceEmail(
to: string,
customerName: string,
invoiceNumber: string,
pdfBuffer: Buffer
): Promise<void> {
const html = await renderInvoiceEmail({ customerName, invoiceNumber });
await this.send({
to,
subject: `Invoice #${invoiceNumber} from ECOSIRE`,
html,
attachments: [
{
filename: `invoice-${invoiceNumber}.pdf`,
content: pdfBuffer,
contentType: 'application/pdf',
},
],
});
}
Frequently Asked Questions
Why export render functions instead of components from email templates?
NestJS runs in a Node.js environment without a JSX transform unless explicitly configured. If you export raw React components and try to render them in NestJS, you get "React is not defined" or JSX-related errors. Exporting an async function renderWelcomeEmail() that calls render() internally means NestJS only imports a plain async function that returns a string — no JSX compilation required on the consuming side.
Should I use Tailwind CSS in email templates?
React Email provides a @react-email/tailwind wrapper that applies Tailwind classes as inline styles during rendering. This is more ergonomic than writing inline style objects. Limitations: responsive utility classes and arbitrary values have partial support. For complex layouts or Outlook-critical content, inline style objects give you more control.
How do I handle email unsubscribe requirements (CAN-SPAM, GDPR)?
Every marketing email (newsletters, promotions) must include a one-click unsubscribe link. Transactional emails (purchase confirmation, password reset) are exempt from CAN-SPAM but must still have clear sender identification. Store unsubscribe preferences in your database and check them before sending any marketing emails.
What is preheader text and why does it matter?
Preheader text is the preview text shown next to the subject line in email clients (typically 40-90 characters). Without it, email clients pull the first visible text from your email body — often navigation links or a logo alt text. Add it as a visually hidden Text element at the top of the body with display: none; maxHeight: 0; overflow: hidden styles. It meaningfully improves open rates.
How do I test email rendering in Outlook?
Outlook 2013-2021 uses Microsoft Word as its HTML renderer, which has severe CSS limitations. Use Email on Acid or Litmus for cross-client screenshots. For local testing, install Outlook and send to yourself, or use Mailtrap's preview inbox. Always use table-based layouts — React Email components use tables internally, which is why they work where raw flexbox HTML fails in Outlook.
How do I prevent emails from going to spam?
Configure SPF, DKIM, and DMARC records on your domain. Use a dedicated sending subdomain (e.g., mail.ecosire.com). Warm up new sending IPs gradually. Keep your HTML clean and avoid spam trigger words. Use an unsubscribe link and honor opt-outs. Monitor your sender reputation via Postmaster Tools (Gmail) and Smart Network Data Services (Microsoft).
Next Steps
Transactional email is often the highest-converting touchpoint in a customer journey. A professional welcome email increases day-7 retention, and well-formatted purchase confirmations reduce support tickets and chargebacks.
ECOSIRE implements the full transactional email stack — React Email templates, non-blocking NestJS integration, AWS SES delivery, and real-time delivery monitoring — on every project. Explore our backend engineering services to learn how we build production email infrastructure.
Written by
ECOSIRE TeamTechnical Writing
The ECOSIRE technical writing team covers Odoo ERP, Shopify eCommerce, AI agents, Power BI analytics, GoHighLevel automation, and enterprise software best practices. Our guides help businesses make informed technology decisions.
ECOSIRE
Grow Your Business with ECOSIRE
Enterprise solutions across ERP, eCommerce, AI, analytics, and automation.
Related Articles
15 Email Sequence Templates for GoHighLevel
Copy-ready email sequence templates for GoHighLevel — covering welcome series, lead nurture, onboarding, re-engagement, sales, and post-purchase flows.
GoHighLevel Funnel Templates for 10 Industries
Ready-to-deploy GoHighLevel funnel templates across 10 industries — with stage-by-stage breakdowns, conversion benchmarks, and optimization tips.
GoHighLevel vs ActiveCampaign: Marketing Automation Showdown
GoHighLevel vs ActiveCampaign compared for 2026: automation depth, deliverability, pricing, CRM features, and which wins for agencies vs solo operators.