React Email Development: Architecture, Rendering Pipelines & Implementation Workflows
Build type-safe transactional email systems with React Email—component patterns, rendering pipelines, and Next.js integration guides.
The evolution of transactional messaging has shifted from brittle string interpolation to declarative UI patterns. React Email Development leverages familiar JSX syntax to construct highly maintainable, component-driven email templates. By abstracting away legacy table structures, engineering teams can implement rigorous type safety, automated testing pipelines, and CI/CD validation. This paradigm aligns with broader shifts in Modern Email Templating Engines, where developer experience and rendering consistency are prioritized over manual HTML composition.
JSX-to-HTML Rendering Pipeline & DOM Constraints
React Email operates by transpiling React components into inline-styled, table-based HTML compatible with legacy email clients. The rendering pipeline relies on @react-email/render to serialize the virtual DOM, stripping unsupported CSS properties and converting modern layout primitives into nested <table> elements. Unlike MJML Component Architecture, which relies on a custom XML parser and proprietary transpilation step, React Email utilizes the standard React reconciliation process, enabling direct integration with existing TypeScript codebases and shared UI libraries. For a head-to-head on compile determinism, payload size, and operational cost, see MJML vs React Email for transactional systems.
Production Rendering Pipeline
// lib/render-email.ts
import { render } from '@react-email/render';
import { WelcomeEmail } from '../templates/WelcomeEmail';
interface WelcomeEmailProps {
name: string;
verifyUrl: string;
}
export async function generateEmailHTML(props: WelcomeEmailProps): Promise<string> {
const html = await render(<WelcomeEmail {...props} />, {
pretty: false // Disable in prod to reduce payload size
});
return html;
}
Note: @react-email/render returns a Promise in async mode. Pass { pretty: true } only in development for readability.
DOM Constraints & Debugging Steps
Email clients ignore <div>-based positioning and require explicit width, align, and valign attributes on table cells. When debugging rendering discrepancies:
- Inspect Serialized Output: Run
console.log(await render(<Component />))to verify table nesting depth. Outlook 2019+ can struggle with very deeply nested tables. - Validate CSS Stripping: Use the React Email preview server to inspect output. Unsupported properties (
gap,grid,vw/vh) are silently dropped. - Force Table Fallbacks: Wrap flex containers in
<table role="presentation">with explicitcellpadding="0" cellspacing="0"to prevent client-specific spacing overrides.
Component Composition & Stateless Data Injection
Effective template architecture requires strict separation of presentation and data injection. Developers should implement stateless functional components with explicit TypeScript interfaces for props. Dynamic content is injected via server-side rendering, ensuring zero client-side JavaScript execution in the final payload.
Strict TypeScript Component Pattern
// components/EmailCard.tsx
import { Container, Heading, Text, Hr, Link } from '@react-email/components';
import type { CSSProperties } from 'react';
interface EmailCardProps {
title: string;
body: string;
accentColor?: CSSProperties['color'];
ctaUrl: string;
}
export const EmailCard: React.FC<EmailCardProps> = ({
title,
body,
accentColor = '#0055FF',
ctaUrl
}) => (
<Container style={{ padding: '24px', backgroundColor: '#F8F9FA', border: '1px solid #E5E7EB' }}>
<Heading style={{ color: accentColor, margin: '0 0 12px' }}>{title}</Heading>
<Text style={{ margin: '0 0 16px', lineHeight: '1.5' }}>{body}</Text>
<Hr style={{ borderColor: '#E5E7EB', margin: '16px 0' }} />
<Link href={ctaUrl} style={{
display: 'inline-block',
padding: '12px 24px',
backgroundColor: accentColor,
color: '#FFFFFF',
textDecoration: 'none',
borderRadius: '4px'
}}>
View Details
</Link>
</Container>
);
Production Rules
- Zero Hooks/State:
useState,useEffect, and context providers are intentionally excluded. The compilation target is static HTML; runtime reactivity breaks deterministic output. - Shared Primitive Package: Isolate
<Button>,<Section>, and<Text>in a scoped@company/email-uipackage. Marketing operations can swap assets without breaking layout constraints. - Prop Validation: Use
zodat the API boundary to validate template payloads before passing them torender().
Framework Integration & Delivery Workflows
Deployment typically involves rendering templates on-demand via API routes. For Next.js implementations, developers can leverage server-side rendering to generate HTML payloads efficiently. A comprehensive guide on Setting up React Email with Next.js details the configuration of preview servers, webhook triggers, and SMTP relay integration.
Next.js API Route
// app/api/send-transactional/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { render } from '@react-email/render';
import { OrderConfirmation } from '@/templates/OrderConfirmation';
export async function POST(req: NextRequest) {
const payload = await req.json();
const html = await render(<OrderConfirmation {...payload} />);
// Forward to delivery provider
return NextResponse.json({ status: 'queued', htmlLength: html.length });
}
Provider-Specific Delivery Configurations
| Provider | Payload Structure | Key Configuration |
|---|---|---|
| Resend | { from, to, subject, react: <Component /> } |
Accepts JSX directly; renders server-side automatically. |
| SendGrid | { personalizations: [{ to }], subject, content: [{ type: 'text/html', value: html }] } |
Requires pre-rendered string. Use sandbox_mode for staging. |
| AWS SES | { Source, Destination: { ToAddresses }, Message: { Subject: { Data }, Body: { Html: { Data: html } } } } |
Use @aws-sdk/client-ses. Enable ConfigurationSetName for tracking. |
// Resend Direct Integration
import { Resend } from 'resend';
const resend = new Resend(process.env.RESEND_API_KEY);
await resend.emails.send({
from: 'notifications@yourdomain.com',
to: ['user@example.com'],
subject: 'Your Order is Confirmed',
react: <OrderConfirmation orderId="ORD-9921" total="$149.00" />,
});
Cross-Client Compatibility & Inline Styling Enforcement
Email clients enforce strict CSS support matrices. React Email addresses this by automatically inlining styles using its rendering pipeline and applying client-safe defaults. Developers must avoid modern CSS features like gap, :hover pseudo-classes, and media queries without progressive enhancement wrappers. While platforms like Liquid for Shopify Emails handle conditional logic at the platform level, React Email requires explicit prop-driven conditionals and pre-rendered variant generation to maintain compatibility across Outlook, Apple Mail, and Gmail.
Tailwind-to-Inline Configuration
// tailwind.config.js (Email-Optimized)
module.exports = {
content: ['./templates/**/*.{tsx,jsx,ts,js}'],
theme: { extend: {} },
plugins: [require('@react-email/tailwind')],
corePlugins: {
preflight: false, // Removes browser resets that break email clients
},
};
Use @react-email/tailwind to wrap your component and enable Tailwind class support with automatic inlining:
import { Tailwind } from '@react-email/tailwind';
export const MyEmail = () => (
<Tailwind>
<div className="bg-white p-6 font-sans">
<h1 className="text-2xl font-bold text-gray-900">Hello</h1>
</div>
</Tailwind>
);
Debugging & CI/CD Validation Pipeline
- Local Preview Server: Run
npx react-email devto launch the preview server athttp://localhost:3000. Inspect compiled output across multiple viewport widths. - Automated Snapshot Testing:
// tests/email-snapshots.spec.ts import { test, expect } from '@playwright/test'; import { render } from '@react-email/render'; import { WelcomeEmail } from '../templates/WelcomeEmail'; test('renders correctly', async ({ page }) => { const html = await render(<WelcomeEmail name="Dev" />); await page.setContent(html); await expect(page.locator('table').first()).toHaveAttribute('role', 'presentation'); }); - Linting Enforcement: Add
eslint-plugin-jsx-a11yto catch missingaltattributes and brokenhrefprotocols. - Post-Render Validation: Run
html-validateagainst the serialized output to strip malformed tags before SMTP injection.
The @react-email/components Primitive Library
The @react-email/components package is the foundation every template should build on. Each primitive renders to the table-and-attribute HTML that legacy clients require, so you never hand-author a <table role="presentation"> wrapper or remember which attribute Outlook needs. Reach for the raw element only when no primitive covers the case.
// components/Receipt.tsx — primitives map to client-safe HTML, not <div> layout
import {
Html, Head, Preview, Body, Container, Section, Row, Column,
Heading, Text, Button, Img, Hr, Link, Font,
} from '@react-email/components';
interface ReceiptProps {
customerName: string;
orderId: string;
total: string;
receiptUrl: string;
}
export function Receipt({ customerName, orderId, total, receiptUrl }: ReceiptProps) {
return (
<Html lang="en">
<Head>
{/* Font: serves a @font-face for Apple Mail / iOS Mail; Outlook 2016-2021 ignores it
and falls through to the fallbackFontFamily below. */}
<Font
fontFamily="Inter"
fallbackFontFamily="Arial"
webFont={{ url: 'https://fonts.gstatic.com/s/inter/v13/UcC.woff2', format: 'woff2' }}
/>
</Head>
{/* Preview: the inbox snippet (Gmail, Apple Mail, Outlook 365 list view). Rendered as a
hidden preheader; keep under ~90 chars or Gmail truncates it. */}
<Preview>Your order {orderId} is confirmed — total {total}</Preview>
<Body style={{ backgroundColor: '#f4f4f5', margin: 0 }}>
<Container style={{ maxWidth: '600px', margin: '0 auto', padding: '24px' }}>
<Heading style={{ fontSize: '20px', color: '#111827' }}>Thanks, {customerName}</Heading>
{/* Row/Column emit a presentation table, NOT flexbox — Outlook 2016/2019/365 (Word
engine) has no flex/grid support, so the primitive avoids it entirely. */}
<Row>
<Column style={{ fontSize: '14px' }}>Order {orderId}</Column>
<Column align="right" style={{ fontSize: '14px', fontWeight: 700 }}>{total}</Column>
</Row>
<Hr style={{ borderColor: '#e5e7eb', margin: '16px 0' }} />
{/* Button: renders a bulletproof table-cell anchor; padding survives Outlook because
it is a cell, not padding on the <a> (which Outlook's Word engine drops). */}
<Button
href={receiptUrl}
style={{ backgroundColor: '#A53860', color: '#ffffff', padding: '12px 24px',
borderRadius: '6px', textDecoration: 'none', fontSize: '16px' }}
>
View receipt
</Button>
<Section style={{ marginTop: '24px' }}>
<Link href="https://yourdomain.com/help" style={{ color: '#A53860' }}>Need help?</Link>
</Section>
</Container>
</Body>
</Html>
);
}
A few primitives carry rendering rules worth memorizing. <Button> is the most important — it expands to a nested table-cell anchor so the padding holds in Outlook 2016/2019/365, where padding declared on an <a> is silently dropped by the Word rendering engine. For pixel-perfect rounded buttons in Outlook you still need VML; the bulletproof email buttons reference covers the conditional-comment fallback the primitive does not inject for you. <Preview> renders the hidden preheader that Gmail, Apple Mail, and Outlook 365 show in the message list — without it the client pulls the first visible text, which is usually a logo alt string. <Img> always needs an explicit width and height; Outlook will otherwise render the image at its intrinsic size and break the column.
render(): How JSX Becomes an HTML String
render() from @react-email/render is where the JSX tree collapses into the final byte stream you hand to an ESP. It runs the standard React reconciler to produce a virtual DOM, serializes that to HTML, strips properties no email client supports (gap, grid, display:flex, viewport units), and inlines the remaining styles onto style attributes. Crucially, no hooks, state, or effects survive — the output is a static, deterministic string. Calling render() twice with the same props yields byte-identical HTML, which is what makes snapshot testing meaningful.
// lib/render.ts — full render with both HTML and a plain-text alternative
import { render } from '@react-email/render';
import { Receipt } from '../components/Receipt';
export async function buildReceipt(props: Parameters<typeof Receipt>[0]) {
const element = <Receipt {...props} />;
// pretty:false strips indentation/newlines — shrinks the payload so Gmail (web/app)
// does not clip the message at its 102KB limit and append a "[Message clipped]" link.
const html = await render(element, { pretty: false });
// plainText:true walks the SAME tree and emits a text/plain alternative. Always send a
// multipart/alternative — Outlook on Windows and spam filters score text-only fallbacks.
const text = await render(element, { plainText: true });
return { html, text };
}
Two options matter in production. pretty: false removes the whitespace that pushes a long receipt past Gmail's 102KB clip threshold. plainText: true walks the same component tree and emits a text/plain body — send both as a multipart/alternative message, because a missing text part hurts deliverability and renders blank for users who force plain text in Outlook. The synchronous renderAsync alias has been folded into render in current versions; render returns a Promise, so always await it.
Type Safety: Props as the Template Contract
The strongest argument for JSX-based templates is that the props interface becomes a compile-checked contract between the code that triggers an email and the template that renders it. A missing orderId is a TypeScript error at build time, not a {{orderId}} literal that ships to a customer's inbox. Pair the interface with runtime validation at the API boundary, because the trigger payload usually arrives as untyped JSON.
// lib/email-contract.ts — compile-time type AND runtime validation share one source
import { z } from 'zod';
import type { z as zType } from 'zod';
// One schema is the single source of truth for the template's required data.
export const ReceiptSchema = z.object({
customerName: z.string().min(1),
orderId: z.string().regex(/^ORD-\d+$/), // reject malformed ids before render()
total: z.string(), // pre-formatted server-side; clients have no Intl
receiptUrl: z.string().url(), // absolute https — relative URLs break in Gmail
});
// The component props type is DERIVED from the schema — they cannot drift apart.
export type ReceiptProps = zType.infer<typeof ReceiptSchema>;
export function validateReceiptPayload(input: unknown): ReceiptProps {
// Throws on missing/invalid fields BEFORE any JSX is rendered or any HTML is sent.
return ReceiptSchema.parse(input);
}
Deriving the component prop type from the Zod schema with z.infer means the two can never drift: change the schema and every template that consumes it fails to compile until updated. Format locale-dependent values — currency, dates — on the server before they enter props, because the email client has no JavaScript runtime and cannot run Intl. This mirrors the server-side formatting discipline in the Handlebars email templates guide.
Reusable Components and a Shared Email-UI Package
Once you have two templates, the header, footer, and button are duplicated. Extract them into a scoped package — @company/email-ui — so design changes propagate without editing every template, and so marketing operations can update a logo or legal footer without touching transactional logic.
// @company/email-ui/src/Layout.tsx — the shared shell every message composes into
import { Html, Head, Body, Container, Section, Img, Text } from '@react-email/components';
import type { ReactNode } from 'react';
export function EmailLayout({ children, preview }: { children: ReactNode; preview?: string }) {
return (
<Html lang="en">
<Head />
<Body style={{ backgroundColor: '#f4f4f5', margin: 0 }}>
<Container style={{ maxWidth: '600px', margin: '0 auto', backgroundColor: '#ffffff' }}>
{/* Logo: width/height REQUIRED — Outlook 2016/2019/365 ignores CSS sizing on <img>. */}
<Section style={{ padding: '24px' }}>
<Img src="https://cdn.yourdomain.com/logo.png" width="120" height="32" alt="Acme" />
</Section>
{children}
<Section style={{ padding: '24px', borderTop: '1px solid #e5e7eb' }}>
{/* Footer text small but >= 13px: Gmail (web) zooms tiny text and breaks layout. */}
<Text style={{ fontSize: '13px', color: '#6b7280' }}>
Acme Inc · <a href="https://yourdomain.com/unsubscribe">Unsubscribe</a>
</Text>
</Section>
</Container>
</Body>
</Html>
);
}
Keep the shared package free of runtime state and side effects — it must compile to static HTML like everything else. Versioning it independently lets you roll a footer change across every transactional template with a single dependency bump, which is the same composability advantage you get from MJML component architecture without leaving TypeScript.
The Local Preview Workflow
The preview server is the fastest feedback loop in React Email. npx react-email dev watches emails/ and serves every template at http://localhost:3000, rendering each one live and exposing a desktop/mobile toggle and the raw HTML source. Author templates with a PreviewProps export so the preview has realistic data without a running backend.
// emails/Receipt.tsx — PreviewProps drive the local dev server, not production sends
import { Receipt } from '../components/Receipt';
export default Receipt;
// The preview server reads this export to render with realistic sample data.
Receipt.PreviewProps = {
customerName: 'Dana Lee',
orderId: 'ORD-9921',
total: '$149.00',
receiptUrl: 'https://yourdomain.com/receipts/ORD-9921',
} satisfies Parameters<typeof Receipt>[0];
The preview renders in Chromium, so it does NOT reproduce Outlook's Word engine or Gmail's CSS stripping — treat it as a layout sketch, not a compatibility verdict. Real cross-client confidence comes from rendering the produced HTML in the Litmus and Email on Acid workflows, or at minimum a local email preview server that captures the actual MIME message.
The Render-to-Inline Pipeline, Step by Step
The full path from JSX to a dispatched message is deterministic and belongs in CI. Each numbered step has one job and a clear failure mode.
- Author the template from
@react-email/componentsprimitives with a typed props interface — no raw<div>layout, no hooks. - Validate the incoming trigger payload with the Zod schema at the API boundary; a malformed payload throws here, before any HTML is generated.
- Render HTML with
await render(element, { pretty: false }). The reconciler runs, unsupported CSS (gap,grid, viewport units) is dropped, and remaining styles are inlined ontostyleattributes. - Render text with
await render(element, { plainText: true })to build themultipart/alternativetext part. - Size-check the HTML against the 102KB Gmail clip limit (
Buffer.byteLength(html)); fail the build if it exceeds the threshold. - Dispatch to the ESP — Resend accepts the JSX directly and renders server-side; SES, SendGrid, and Postmark take the pre-rendered string. The network call happens only after rendering succeeds.
Provider and Client Constraint Table
render() produces the same HTML regardless of provider, but how you hand it off differs. These constraints describe the dispatch contract and the rendering reality on the receiving end.
| Constraint | Why it matters | Affected client / provider |
|---|---|---|
Padding on <a> is dropped; needs table-cell <Button> |
Buttons collapse to text size | Outlook 2016/2019/365 (Word engine) |
gap, grid, display:flex silently stripped by render() |
Use <Row>/<Column> primitives |
Outlook 2016/2019/365, Samsung Email |
@media queries must survive; inline does not cover them |
Responsive resize breaks | iOS Mail, Apple Mail |
| Message clipped above 102KB | Use pretty:false, trim assets |
Gmail (web/app) |
<Img> needs explicit width/height |
Image renders at intrinsic size | Outlook 2016/2019/365 |
Accepts JSX directly via react: field |
No manual render() needed |
Resend |
| Requires pre-rendered HTML string | Call render() first; use sandbox_mode for staging |
SendGrid |
Requires SendRawEmail for multipart/alternative |
Build the MIME yourself or use the SDK helper | Amazon SES |
Inbound X-PM-* headers / MessageStream |
Set the stream for transactional vs broadcast | Postmark |
Named-Symptom Debugging
Button padding gone, button is just blue text — you styled a raw <a> instead of <Button>. Outlook 2016/2019/365's Word engine drops padding on anchors; switch to the <Button> primitive (table-cell) or add a VML fallback per the bulletproof buttons reference.
Columns stack vertically in Outlook but look fine in Apple Mail — you used a <div> with display:flex, which render() stripped. Replace it with <Row>/<Column>, which emit a presentation table that Outlook's Word engine lays out correctly.
Gmail shows "[Message clipped]" — the HTML exceeds 102KB. Render with { pretty: false }, host images on a CDN instead of base64-embedding them, and remove unused inline styles.
render(...) resolves to [object Promise] in the email — you forgot to await. render is async; the unawaited Promise was coerced to a string and sent. Always await render(...).
Preheader shows the logo alt text — no <Preview> element. Add <Preview> as the first child of <Body>; Gmail, Apple Mail, and Outlook 365 read it for the message-list snippet.
Web font ignored in Outlook — expected. Outlook 2016/2019/365 does not honor @font-face; confirm <Font fallbackFontFamily> is set so it degrades to Arial rather than a default serif.
Frequently Asked Questions
Can I use React hooks in an email template? No. useState, useEffect, and context never run — render() produces static HTML with no client runtime. Any value that would come from a hook must be passed in as a prop and resolved server-side.
Do I still need a CSS inliner like Juice? render() inlines styles for you, so a separate inliner is usually redundant. The exception is @media queries, which inlining cannot move — keep those in a <style> block (React Email preserves it) for iOS Mail and Apple Mail. For advanced control over MSO conditional comments, the inline CSS automation reference still applies to the produced HTML.
Resend or a generic ESP? Resend accepts the JSX component directly via its react: field and renders server-side, which is the least code. SES, SendGrid, and Postmark need the pre-rendered string from render() — slightly more wiring but no provider lock-in. The setting up React Email with Next.js guide shows both paths.
How do I test cross-client rendering? The local preview server is Chromium-only and will not surface Outlook or Gmail quirks. Feed the produced HTML into automated cross-client rendering or snapshot testing so a layout regression fails CI before it ships.
Is React Email a better fit than MJML? It depends on whether your team prefers TypeScript components or an XML compiler. The MJML vs React Email for transactional systems deep-dive compares determinism, payload size, and operational cost head-to-head.
Validation Checklist
- Templates built from
@react-email/componentsprimitives — no raw<div>flex/grid layout - Every template has a typed props interface; payload validated with Zod at the API boundary
render()is alwaysawaited; production uses{ pretty: false }- A
plainText: truebody is rendered and sent asmultipart/alternative <Preview>set on every template for the inbox snippet<Img>elements carry explicitwidthandheightfor Outlook 2016/2019/365- CTAs use the
<Button>primitive (or a VML fallback) so padding holds in Outlook @mediarules kept in a<style>block and verified in iOS Mail / Apple Mail- Rendered HTML measured under 102KB to avoid Gmail clipping
- Locale-dependent values formatted server-side before entering props
- Produced HTML validated cross-client (not just in the Chromium preview)
Conclusion
Adopting a component-driven approach to transactional messaging reduces technical debt and accelerates iteration cycles. By enforcing strict typing, leveraging automated preview environments, and adhering to email-specific rendering constraints, engineering teams can deliver reliable, high-fidelity communications at scale. React Email Development bridges the gap between modern frontend practices and legacy email infrastructure, providing a sustainable foundation for enterprise-grade messaging systems.
Related
- Setting up React Email with Next.js — wire the render pipeline into App Router route handlers
- MJML vs React Email for transactional systems — weigh JSX against the XML compiler for your stack
- MJML Component Architecture — the declarative XML alternative to JSX templates
- Liquid for Shopify Emails — platform-level conditional logic for storefront notifications
← Back to Modern Email Templating Engines