React Email development server for previewing and testing email templates. Hot reload, responsive testing, dark mode support. Interactive environment for designing transactional emails with Resend integration.
Quick Start
Preview email templates in 30 seconds:
pnpm --filter email devOpens at localhost:3500. Edit templates, see changes instantly. Skip to Quick Start →
Why email?
Email templates scattered across applications. No live preview for email changes. Manual testing across email clients. Styling inconsistencies in production emails. Duplicated email logic. No shared template components.
email solves this by centralizing all email templates in a React Email development environment with hot reload,
responsive preview, and integration with @repo/email for production use.
Production-ready with React Email 5, hot reload, responsive/dark mode testing, Resend integration, shared components, Vitest testing, and HTML export.
Use cases
- Template Development — Build and preview email templates with hot reload
- Responsive Testing — Test emails on desktop and mobile viewports
- Dark Mode — Preview dark mode email support
- Client Testing — Export HTML to test in email clients (Gmail, Outlook)
- Production Integration — Templates consumed by
@repo/emailwith Resend
How it works
email uses React Email components to build email templates:
// emails/auth/MagicLink.tsx
import {
Body,
Container,
Head,
Heading,
Html,
Link,
Preview,
Text,
} from "@react-email/components";
interface MagicLinkEmailProps {
magicLink: string;
email: string;
}
export default function MagicLinkEmail({
magicLink = "https://example.com/auth/magic?token=xxx",
email = "user@example.com",
}: MagicLinkEmailProps) {
return (
<Html>
<Head />
<Preview>Sign in to your account</Preview>
<Body style={main}>
<Container style={container}>
<Heading style={h1}>Sign in to Your Account</Heading>
<Text style={text}>Click the link below to sign in as {email}:</Text>
<Link href={magicLink} style={button}>
Sign In
</Link>
</Container>
</Body>
</Html>
);
}
const main = { backgroundColor: "#f6f9fc", fontFamily: "Arial, sans-serif" };
const container = { backgroundColor: "#ffffff", margin: "0 auto", padding: "40px" };
const h1 = { color: "#1a1a1a", fontSize: "24px", fontWeight: "bold" };
const text = { color: "#4a4a4a", fontSize: "16px", lineHeight: "24px" };
const button = {
backgroundColor: "#0066ff",
borderRadius: "4px",
color: "#ffffff",
padding: "12px 24px",
textDecoration: "none",
};Uses React Email for component-based templates, Resend for production sending, and hot reload for instant preview.
Key features
Hot Reload — Live preview of email template changes
Responsive Testing — Desktop and mobile viewport previews
Dark Mode — Test dark mode email rendering
Resend Integration — Production email sending with Resend API
Shared Components — Reusable header, footer, button components
HTML Export — Export templates as static HTML files
Quick Start
1. Start the email dev server
pnpm --filter email dev2. Browse templates
Open http://localhost:3500 and browse available email templates.
3. Create a new template
import { Html, Head, Preview, Body, Container, Heading, Text } from "@react-email/components";
interface AccountCreatedProps {
name: string;
}
export default function AccountCreatedEmail({ name = "User" }: AccountCreatedProps) {
return (
<Html>
<Head />
<Preview>Welcome to OneApp!</Preview>
<Body style={{ backgroundColor: "#f6f9fc", fontFamily: "Arial, sans-serif" }}>
<Container style={{ padding: "40px" }}>
<Heading>Welcome, {name}!</Heading>
<Text>Your account has been successfully created.</Text>
</Container>
</Body>
</Html>
);
}4. Use in production
import { sendAccountCreated } from "@repo/email";
// After user signs up
await sendAccountCreated({
to: user.email,
name: user.name
});That's it! Your email template appears in the dev server with hot reload, and can be sent in production via
@repo/email with Resend.
Development Tip
Use default props in templates for realistic preview data: { name = "John Doe" } instead of { name: string }.
Technical Details
For Developers: Technical implementation details
Overview
| Property | Value |
|---|---|
| Location | platform/apps/email |
| Port | 3500 (development) |
| Framework | React Email 5.0 |
| Integration | @repo/email (production email package) |
| Testing | Vitest 4.0 |
| Deployment | Templates bundled with @repo/email |
| Tech Stack | React 19, TypeScript 5, Resend |
Project Structure
email/
├── emails/ # Email templates
│ ├── auth/ # Authentication emails
│ │ ├── MagicLink.tsx # Passwordless login link
│ │ ├── PasswordReset.tsx # Password reset link
│ │ └── EmailVerification.tsx # Account verification
│ ├── notifications/ # Notification emails
│ │ ├── Welcome.tsx # Welcome email
│ │ ├── ApiKeyCreated.tsx # API key notification
│ │ └── AccountCreated.tsx # Account creation
│ └── transactional/ # Transactional emails
│ ├── OrgInvitation.tsx # Team invitation
│ ├── Contact.tsx # Contact form response
│ └── Receipt.tsx # Purchase receipt
├── components/ # Shared email components
│ ├── EmailLayout.tsx # Base layout wrapper
│ ├── Header.tsx # Email header
│ ├── Footer.tsx # Email footer
│ ├── Button.tsx # Call-to-action button
│ └── Logo.tsx # Company logo
├── __tests__/ # Email template tests
│ ├── MagicLink.test.tsx
│ ├── Welcome.test.tsx
│ └── OrgInvitation.test.tsx
├── static/ # Static assets (images)
│ ├── logo.png
│ └── social-icons/
├── env.ts # Environment configuration
├── scripts/ # Build and export scripts
│ └── export-html.ts # HTML export script
├── vitest.config.ts # Test configuration
└── package.json # Dependencies and scriptsAvailable Templates
Authentication Emails
| Template | File Path | Description | Trigger Event |
|---|---|---|---|
MagicLink | emails/auth/MagicLink.tsx | Passwordless login link | Sign in request |
PasswordReset | emails/auth/PasswordReset.tsx | Password reset link | Forgot password |
EmailVerification | emails/auth/EmailVerification.tsx | Account verification link | Registration |
Notification Emails
| Template | File Path | Description | Trigger Event |
|---|---|---|---|
Welcome | emails/notifications/Welcome.tsx | User onboarding | Account creation |
ApiKeyCreated | emails/notifications/ApiKeyCreated.tsx | API key notification | Key generation |
AccountCreated | emails/notifications/AccountCreated.tsx | Account creation | Signup completion |
Transactional Emails
| Template | File Path | Description | Trigger Event |
|---|---|---|---|
OrgInvitation | emails/transactional/OrgInvitation.tsx | Team invitation | Invite sent |
Contact | emails/transactional/Contact.tsx | Contact form response | Form submission |
Receipt | emails/transactional/Receipt.tsx | Purchase receipt | Payment completed |
Writing Email Templates
Basic Template Structure
// emails/auth/MagicLink.tsx
import {
Body,
Container,
Head,
Heading,
Html,
Link,
Preview,
Text,
} from "@react-email/components";
interface MagicLinkEmailProps {
magicLink: string;
email: string;
}
export default function MagicLinkEmail({
magicLink = "https://example.com/auth/magic?token=xxx",
email = "user@example.com",
}: MagicLinkEmailProps) {
return (
<Html>
<Head />
<Preview>Sign in to your account</Preview>
<Body style={main}>
<Container style={container}>
<Heading style={h1}>Sign in to Your Account</Heading>
<Text style={text}>
Click the link below to sign in as {email}:
</Text>
<Link href={magicLink} style={button}>
Sign In
</Link>
<Text style={footer}>
If you didn't request this, you can safely ignore this email.
</Text>
</Container>
</Body>
</Html>
);
}
// Inline styles (required for email clients)
const main = {
backgroundColor: "#f6f9fc",
fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif",
};
const container = {
backgroundColor: "#ffffff",
margin: "0 auto",
padding: "40px 20px",
borderRadius: "8px",
maxWidth: "600px",
};
const h1 = {
color: "#1a1a1a",
fontSize: "24px",
fontWeight: "bold",
margin: "0 0 20px",
};
const text = {
color: "#4a4a4a",
fontSize: "16px",
lineHeight: "24px",
margin: "0 0 20px",
};
const button = {
backgroundColor: "#0066ff",
borderRadius: "4px",
color: "#ffffff",
display: "inline-block",
fontSize: "16px",
fontWeight: "bold",
padding: "12px 24px",
textDecoration: "none",
textAlign: "center" as const,
margin: "20px 0",
};
const footer = {
color: "#8a8a8a",
fontSize: "14px",
lineHeight: "20px",
margin: "40px 0 0",
};Key Points:
- Use default prop values for preview mode
- All styles must be inline objects
- Use
as constfor CSS values that need literal types - Include
<Preview>for email preview text - Keep layout simple (no flexbox, grid, or advanced CSS)
Using Shared Components
// components/EmailLayout.tsx
import {
Body,
Container,
Head,
Html,
Preview,
Section,
} from "@react-email/components";
import { Header } from "./Header";
import { Footer } from "./Footer";
interface EmailLayoutProps {
preview: string;
children: React.ReactNode;
}
export function EmailLayout({ preview, children }: EmailLayoutProps) {
return (
<Html>
<Head />
<Preview>{preview}</Preview>
<Body style={main}>
<Container style={container}>
<Header />
<Section style={content}>{children}</Section>
<Footer />
</Container>
</Body>
</Html>
);
}
const main = {
backgroundColor: "#f6f9fc",
fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif",
};
const container = {
backgroundColor: "#ffffff",
margin: "0 auto",
maxWidth: "600px",
borderRadius: "8px",
};
const content = {
padding: "40px 20px",
};Reusable Button Component
// components/Button.tsx
import { Link } from "@react-email/components";
interface ButtonProps {
href: string;
children: React.ReactNode;
variant?: "primary" | "secondary";
}
export function Button({ href, children, variant = "primary" }: ButtonProps) {
const variantStyles = {
primary: {
backgroundColor: "#0066ff",
color: "#ffffff",
},
secondary: {
backgroundColor: "#f0f0f0",
color: "#1a1a1a",
},
};
return (
<Link
href={href}
style={{
...baseStyle,
...variantStyles[variant],
}}
>
{children}
</Link>
);
}
const baseStyle = {
display: "inline-block",
fontSize: "16px",
fontWeight: "bold",
padding: "12px 24px",
borderRadius: "4px",
textDecoration: "none",
textAlign: "center" as const,
};Template with Layout and Components
// emails/notifications/Welcome.tsx
import { Heading, Text } from "@react-email/components";
import { EmailLayout } from "../../components/EmailLayout";
import { Button } from "../../components/Button";
interface WelcomeEmailProps {
name: string;
dashboardUrl: string;
}
export default function WelcomeEmail({
name = "John Doe",
dashboardUrl = "https://app.example.com/dashboard",
}: WelcomeEmailProps) {
return (
<EmailLayout preview={`Welcome to OneApp, ${name}!`}>
<Heading style={h1}>Welcome, {name}!</Heading>
<Text style={text}>
Thank you for joining OneApp. We're excited to have you on board!
</Text>
<Text style={text}>
Get started by exploring your dashboard:
</Text>
<Button href={dashboardUrl}>Go to Dashboard</Button>
<Text style={footer}>
If you have any questions, feel free to reply to this email.
</Text>
</EmailLayout>
);
}
const h1 = {
color: "#1a1a1a",
fontSize: "24px",
fontWeight: "bold",
margin: "0 0 20px",
};
const text = {
color: "#4a4a4a",
fontSize: "16px",
lineHeight: "24px",
margin: "0 0 16px",
};
const footer = {
color: "#8a8a8a",
fontSize: "14px",
lineHeight: "20px",
marginTop: "40px",
};Responsive Email Design
Mobile-Friendly Styles
// Use max-width instead of fixed width
const container = {
margin: "0 auto",
maxWidth: "600px", // Not width: 600px
width: "100%",
};
// Use relative units for padding
const section = {
padding: "20px", // Works on all screen sizes
};
// Stack columns on mobile
import { Column, Row, Section } from "@react-email/components";
<Section>
<Row>
<Column style={{ width: "50%", minWidth: "280px" }}>
Left content
</Column>
<Column style={{ width: "50%", minWidth: "280px" }}>
Right content
</Column>
</Row>
</Section>Dark Mode Support
// Use data attributes for dark mode
const text = {
color: "#1a1a1a",
// Add dark mode color via media query
"@media (prefers-color-scheme: dark)": {
color: "#ffffff",
},
};
// Or use separate dark mode styles
export default function EmailWithDarkMode({ name }: Props) {
return (
<Html>
<Head>
<style>{`
@media (prefers-color-scheme: dark) {
.dark-mode-text {
color: #ffffff !important;
}
.dark-mode-bg {
background-color: #1a1a1a !important;
}
}
`}</style>
</Head>
<Body>
<Text className="dark-mode-text">Content</Text>
</Body>
</Html>
);
}Testing Email Templates
Unit Tests
// __tests__/MagicLink.test.tsx
import { render } from "@react-email/render";
import { describe, it, expect } from "vitest";
import MagicLinkEmail from "../emails/auth/MagicLink";
describe("MagicLinkEmail", () => {
it("renders with magic link", async () => {
const html = await render(
<MagicLinkEmail
magicLink="https://example.com/auth?token=abc123"
email="test@example.com"
/>
);
expect(html).toContain("Sign in to Your Account");
expect(html).toContain("test@example.com");
expect(html).toContain("https://example.com/auth?token=abc123");
});
it("includes preview text", async () => {
const html = await render(
<MagicLinkEmail
magicLink="https://example.com/auth?token=abc123"
email="test@example.com"
/>
);
expect(html).toContain("Sign in to your account");
});
it("renders button with correct href", async () => {
const html = await render(
<MagicLinkEmail
magicLink="https://example.com/auth?token=abc123"
email="test@example.com"
/>
);
expect(html).toContain('href="https://example.com/auth?token=abc123"');
});
});Running Tests
# Run all email tests
pnpm --filter email test
# Run tests in watch mode
pnpm --filter email test:watch
# Run tests with coverage
pnpm --filter email test:coverageIntegration with @repo/email
Sending Emails in Production
// @repo/email/src/send.ts
import { Resend } from "resend";
import MagicLinkEmail from "./templates/MagicLink";
import WelcomeEmail from "./templates/Welcome";
const resend = new Resend(process.env.RESEND_API_KEY);
export async function sendMagicLink({ to, magicLink }: { to: string; magicLink: string }) {
const { data, error } = await resend.emails.send({
from: process.env.EMAIL_FROM || "noreply@yourdomain.com",
to,
subject: "Sign in to your account",
react: MagicLinkEmail({ magicLink, email: to })
});
if (error) {
throw new Error(`Failed to send magic link: ${error.message}`);
}
return data;
}
export async function sendWelcome({ to, name, dashboardUrl }: { to: string; name: string; dashboardUrl: string }) {
const { data, error } = await resend.emails.send({
from: process.env.EMAIL_FROM || "noreply@yourdomain.com",
to,
subject: `Welcome to OneApp, ${name}!`,
react: WelcomeEmail({ name, dashboardUrl })
});
if (error) {
throw new Error(`Failed to send welcome email: ${error.message}`);
}
return data;
}Using in Applications
// apps/oneapp-onstage/app/api/auth/magic/route.ts
import { sendMagicLink } from "@repo/email";
import { NextRequest } from "next/server";
export async function POST(request: NextRequest) {
const { email } = await request.json();
// Generate magic link token
const token = generateToken();
const magicLink = `${process.env.NEXT_PUBLIC_APP_URL}/auth/magic?token=${token}`;
// Send email
await sendMagicLink({
to: email,
magicLink
});
return Response.json({ success: true });
}HTML Export
Exporting Templates
# Export all templates to static HTML
pnpm --filter email export
# Output: platform/apps/email/out/
# - magic-link.html
# - password-reset.html
# - welcome.html
# - ...Export Script
// scripts/export-html.ts
import { render } from "@react-email/render";
import { writeFileSync, mkdirSync } from "node:fs";
import { join } from "node:path";
import MagicLinkEmail from "../emails/auth/MagicLink";
import WelcomeEmail from "../emails/notifications/Welcome";
async function exportEmails() {
const outDir = join(__dirname, "../out");
mkdirSync(outDir, { recursive: true });
// Export MagicLink
const magicLinkHtml = await render(
<MagicLinkEmail
magicLink="https://example.com/auth?token=PLACEHOLDER"
email="user@example.com"
/>
);
writeFileSync(join(outDir, "magic-link.html"), magicLinkHtml);
// Export Welcome
const welcomeHtml = await render(
<WelcomeEmail
name="John Doe"
dashboardUrl="https://example.com/dashboard"
/>
);
writeFileSync(join(outDir, "welcome.html"), welcomeHtml);
console.log("✅ Exported email templates to out/");
}
exportEmails();Testing in Email Clients
Use exported HTML files to test in email clients:
- Open exported HTML in browser
- Copy HTML source
- Paste into email client test tool (Litmus, Email on Acid)
- Test rendering in Gmail, Outlook, Apple Mail, etc.
Environment Variables
# Resend API (for production sending)
RESEND_API_KEY="re_..."
RESEND_TOKEN="re_..." # Alternative name
# Email Configuration
EMAIL_FROM="noreply@yourdomain.com"
EMAIL_REPLY_TO="support@yourdomain.com"
# React Email Dev Server
REACT_EMAIL_PORT=3500
# Environment
NODE_ENV="development"
VERCEL_ENV="preview"Best Practices
1. Use Inline Styles
Email clients have very limited CSS support. Always use inline styles:
// ✅ Good - Inline styles
<Text style={{ color: "#333", fontSize: "16px" }}>Content</Text>
// ❌ Bad - CSS classes (won't work in most clients)
<Text className="text-gray-700 text-base">Content</Text>
// ❌ Bad - External stylesheets (not supported)
<link rel="stylesheet" href="styles.css" />2. Table-Based Layouts
For maximum compatibility, use table-based layouts:
import { Column, Row, Section } from "@react-email/components";
<Section>
<Row>
<Column style={{ width: "50%", padding: "10px" }}>
Left content
</Column>
<Column style={{ width: "50%", padding: "10px" }}>
Right content
</Column>
</Row>
</Section>3. Web-Safe Fonts
Use web-safe fonts with fallbacks:
const fontFamily = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, Helvetica, sans-serif";
// Web-safe fonts:
// - Arial
// - Helvetica
// - Georgia
// - Times New Roman
// - Courier New
// - Verdana4. Image Hosting
Host images on a CDN with absolute URLs:
import { Img } from "@react-email/components";
// ✅ Good - Absolute URL
<Img
src="https://cdn.yourdomain.com/logo.png"
alt="Company Logo"
width={150}
height={50}
/>
// ❌ Bad - Relative path (won't work in emails)
<Img src="/logo.png" alt="Logo" />5. Accessible Emails
Ensure emails are accessible:
// Use semantic HTML
<Heading>Main Title</Heading>
<Text>Body text</Text>
// Add alt text to images
<Img src="..." alt="Descriptive alt text" />
// Use sufficient color contrast
const text = { color: "#333" }; // Good contrast
const background = { backgroundColor: "#fff" };
// Use descriptive link text
<Link href="...">View your dashboard</Link> // ✅ Good
<Link href="...">Click here</Link> // ❌ Bad6. Preheader Text
Use <Preview> for preheader text (shows in inbox preview):
<Preview>Sign in to your account - Magic link inside</Preview>Best practices:
- Keep to 40-100 characters
- Make it actionable
- Don't duplicate the subject line
Scripts Reference
{
"scripts": {
"dev": "email dev --port 3500",
"build": "email build",
"export": "email export",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"lint": "eslint emails components",
"typecheck": "tsc --noEmit"
}
}Command Descriptions:
dev— Start hot-reload dev serverbuild— Build email templatesexport— Export templates as static HTMLtest— Run Vitest teststest:watch— Run tests in watch modetest:coverage— Run tests with coverage reportlint— Lint email templatestypecheck— Type check TypeScript
Troubleshooting
Styles Not Rendering
Issue: Styles not appearing in email preview.
Solution: Ensure all styles are inline objects, not classes:
// ✅ Correct
<Text style={{ color: "#333" }}>Content</Text>
// ❌ Incorrect
<Text className="text-gray-700">Content</Text>Images Not Loading
Issue: Images don't load in email clients.
Solution: Use absolute URLs and ensure images are hosted on a public CDN:
<Img
src="https://cdn.yourdomain.com/logo.png" // Absolute URL
alt="Logo"
width={150}
height={50}
/>Hot Reload Not Working
Issue: Changes not reflecting in preview.
Solution: Restart the dev server:
pnpm --filter email devResend API Errors
Issue: Emails failing to send with Resend.
Solution: Verify API key and sender domain:
# Check environment variables
echo $RESEND_API_KEY
echo $EMAIL_FROM
# Verify domain in Resend dashboard:
# https://resend.com/domainsRelated Documentation
- @repo/email — Production email package
- oneapp-onstage — Application using email templates
- Resend — Email sending service