Skip to main content

React Email Templates 2026: Production Guide (Tailwind, Spam-Tested)

Ship transactional emails that deliver in 2026: React Email, Tailwind, dark mode, spam-score tested across Gmail, Outlook, Apple Mail. 4 templates inside.

E
ECOSIRE Research and Development Team
|March 19, 2026Updated May 4, 202611 min read2.0k Words|

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…

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 render functions from your templates, not raw components — avoids JSX compilation issues in NestJS
  • Use @react-email/components primitives (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, or calc() 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 info@ecosire.com.
            </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:

ClientKey Issues
Outlook 2016-2021No flexbox, no border-radius, Word rendering engine
Gmail (web)Strips <style> tags, clips emails >102KB
Apple MailGood rendering, but dark mode inverts colors
Gmail (iOS)Generally good, watches for minimum font sizes
Samsung MailOlder 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.

E

Written by

ECOSIRE Team

Technical 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.