React Email16 min read

React Email Error Boundaries: Catch Render Failures Before They Reach Production

Build production-grade error boundaries for React Email. Catch runtime failures, render fallbacks, log errors, and prevent silent email failures with defensive rendering patterns.

R

React Emails Pro

March 4, 2026

Your password reset email fails to render. Your app catches nothing. The user gets a blank email or no email at all. By the time you notice, fifty customers are locked out and angry.

React Email templates are React components — which means they can throw runtime errors. Missing props, undefined data, broken images, formatting failures. If you don't catch these at render time, they become silent failures in production.

Error boundaries for React Email aren't about perfect code. They're about containing failures so one bad data point doesn't kill your entire email flow.

Why error boundaries matter for email rendering

Unlike web apps where users see error screens, email failures are invisible. The render blows up, the send call fails, and the user just never receives the email.

Common React Email runtime failures:

  • Cannot read property 'name' of undefined — missing user data
  • toFixed is not a function — currency formatting on null prices
  • Invalid Date — malformed timestamps
  • map() on undefined — unexpected array structure

Without error boundaries, these failures crash the entire email render. With them, you catch the error, log it, send a fallback, and preserve the user experience.


Building a React Email error boundary

React's built-in error boundary pattern works for email templates, but you need to adapt it for server-side rendering and logging.

Basic email error boundary component

components/EmailErrorBoundary.tsx
import { Component, ReactNode } from "react";

interface Props {
  children: ReactNode;
  fallback?: ReactNode;
  onError?: (error: Error, errorInfo: React.ErrorInfo) => void;
}

interface State {
  hasError: boolean;
  error?: Error;
}

export class EmailErrorBoundary extends Component<Props, State> {
  constructor(props: Props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error: Error): State {
    return { hasError: true, error };
  }

  componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
    // Log to your monitoring system
    console.error("Email render error:", error, errorInfo);
    
    // Call optional error handler
    this.props.onError?.(error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      // Render fallback or simple text version
      return this.props.fallback || null;
    }

    return this.props.children;
  }
}

Integration with email sending

Wrap your email template in the error boundary before calling render(). This catches errors during the HTML generation phase.

lib/send-email.ts
import { render } from "@react-email/render";
import { Resend } from "resend";
import { EmailErrorBoundary } from "@/components/EmailErrorBoundary";
import PasswordResetEmail from "@/emails/PasswordResetEmail";

const resend = new Resend(process.env.RESEND_API_KEY);

export async function sendPasswordReset(email: string, resetToken: string) {
  let html: string;
  let renderError: Error | null = null;

  try {
    // Wrap template in error boundary
    html = render(
      <EmailErrorBoundary
        fallback={<SimpleFallbackEmail resetToken={resetToken} />}
        onError={(error) => {
          renderError = error;
        }}
      >
        <PasswordResetEmail resetToken={resetToken} />
      </EmailErrorBoundary>
    );
  } catch (error) {
    // Catastrophic render failure
    console.error("Fatal email render error:", error);
    
    // Send plain text fallback
    html = `Password reset link: https://app.com/reset/${resetToken}`;
  }

  // Log render error but still attempt send
  if (renderError) {
    await logEmailRenderError({
      template: "password-reset",
      error: renderError.message,
      recipient: email,
    });
  }

  const { data, error } = await resend.emails.send({
    from: "security@yourapp.com",
    to: email,
    subject: "Password reset requested",
    html,
  });

  if (error) {
    throw new Error(`Email send failed: ${error.message}`);
  }

  return data;
}
Always provide a fallback component or at minimum a plain text version. A broken email is better than no email.

Fallback strategies for failed renders

When the main template fails, you need a degraded but functional version. Here are three strategies:

1) Simple text-based fallback

Strip all styling and render a plain text version with the essential data.

emails/fallbacks/SimpleFallback.tsx
import { Html, Text, Link } from "@react-email/components";

interface Props {
  resetToken: string;
}

export function SimpleFallbackEmail({ resetToken }: Props) {
  const resetUrl = `https://yourapp.com/reset/${resetToken}`;

  return (
    <Html>
      <Text>You requested a password reset.</Text>
      <Text>
        Click here to reset: <Link href={resetUrl}>{resetUrl}</Link>
      </Text>
      <Text>This link expires in 1 hour.</Text>
      <Text>
        If you didn't request this, you can safely ignore this email.
      </Text>
    </Html>
  );
}

2) Safe subset fallback

Render only the components you know are stable. Skip dynamic lists, conditional rendering, and complex formatting.

emails/fallbacks/SafeSubsetFallback.tsx
import {
  Html,
  Head,
  Body,
  Container,
  Heading,
  Text,
  Button,
} from "@react-email/components";

interface Props {
  userName?: string;
  resetToken: string;
}

export function SafeSubsetFallback({ userName, resetToken }: Props) {
  const resetUrl = `https://yourapp.com/reset/${resetToken}`;

  return (
    <Html>
      <Head />
      <Body style={{ fontFamily: "sans-serif", padding: "20px" }}>
        <Container>
          <Heading>Password Reset</Heading>
          <Text>
            {userName ? `Hi ${userName},` : "Hi,"}
          </Text>
          <Text>Click the button below to reset your password:</Text>
          <Button
            href={resetUrl}
            style={{
              background: "#000",
              color: "#fff",
              padding: "12px 20px",
              borderRadius: "4px",
            }}
          >
            Reset Password
          </Button>
          <Text style={{ color: "#666", fontSize: "14px" }}>
            This link expires in 1 hour.
          </Text>
        </Container>
      </Body>
    </Html>
  );
}

3) Pre-rendered cached fallback

For critical emails, pre-render a generic fallback at build time and use it when runtime rendering fails.

lib/fallback-cache.ts
import { render } from "@react-email/render";
import { GenericPasswordResetFallback } from "@/emails/fallbacks/generic";

// Pre-render at build/deploy time
const CACHED_FALLBACKS = {
  "password-reset": render(<GenericPasswordResetFallback />),
  "email-verification": render(<GenericEmailVerificationFallback />),
  "magic-link": render(<GenericMagicLinkFallback />),
};

export function getFallbackHtml(templateName: string): string {
  return CACHED_FALLBACKS[templateName] || CACHED_FALLBACKS["password-reset"];
}

// Usage in send function
try {
  html = render(<PasswordResetEmail {...props} />);
} catch (error) {
  console.error("Render failed, using cached fallback");
  html = getFallbackHtml("password-reset").replace(
    "{{RESET_TOKEN}}",
    resetToken
  );
}

Testing error boundary behavior

You need to actively test failure scenarios. Don't wait for production to discover your error handling is broken.

Unit tests for error boundaries

__tests__/EmailErrorBoundary.test.tsx
import { render } from "@react-email/render";
import { EmailErrorBoundary } from "@/components/EmailErrorBoundary";
import { SimpleFallback } from "@/emails/fallbacks/SimpleFallback";

// Component that always throws
function BrokenTemplate() {
  throw new Error("Template render failed");
}

describe("EmailErrorBoundary", () => {
  it("catches render errors and uses fallback", () => {
    const html = render(
      <EmailErrorBoundary fallback={<SimpleFallback resetToken="test123" />}>
        <BrokenTemplate />
      </EmailErrorBoundary>
    );

    // Should render fallback, not crash
    expect(html).toContain("test123");
    expect(html).not.toContain("Template render failed");
  });

  it("calls onError callback when error occurs", () => {
    const onError = jest.fn();

    render(
      <EmailErrorBoundary
        fallback={<SimpleFallback resetToken="test" />}
        onError={onError}
      >
        <BrokenTemplate />
      </EmailErrorBoundary>
    );

    expect(onError).toHaveBeenCalled();
    expect(onError.mock.calls[0][0].message).toBe("Template render failed");
  });

  it("renders children normally when no error", () => {
    const html = render(
      <EmailErrorBoundary>
        <div>Working template</div>
      </EmailErrorBoundary>
    );

    expect(html).toContain("Working template");
  });
});

Integration tests with bad data

Test your full sending flow with malformed props to verify fallback behavior works end-to-end.

__tests__/email-sending.test.ts
import { sendPasswordReset } from "@/lib/send-email";

describe("Email sending with error boundaries", () => {
  it("handles missing user data gracefully", async () => {
    // Simulate missing user props
    const result = await sendPasswordReset(
      "user@example.com",
      undefined as any // Force bad data
    );

    // Should still send (using fallback)
    expect(result.id).toBeDefined();
  });

  it("logs render errors but completes send", async () => {
    const logSpy = jest.spyOn(console, "error");

    await sendPasswordReset("user@example.com", null as any);

    // Error should be logged
    expect(logSpy).toHaveBeenCalled();
  });
});
Don't just test happy paths. Deliberately inject bad data to verify your error boundaries work under real-world failure conditions.

Monitoring and alerting on render failures

Error boundaries are only useful if you know when they trigger. Integrate with your monitoring stack to track render failures.

Structured logging for render errors

lib/email-logger.ts
interface EmailRenderError {
  template: string;
  error: string;
  stack?: string;
  recipient: string;
  timestamp: string;
  props?: Record<string, any>;
}

export async function logEmailRenderError(data: Omit<EmailRenderError, "timestamp">) {
  const errorLog: EmailRenderError = {
    ...data,
    timestamp: new Date().toISOString(),
  };

  // Send to your logging service
  console.error("[EMAIL_RENDER_ERROR]", JSON.stringify(errorLog));

  // Optional: send to Sentry, Datadog, etc.
  if (process.env.SENTRY_DSN) {
    Sentry.captureException(new Error(data.error), {
      tags: {
        template: data.template,
        recipient: data.recipient,
      },
      extra: data.props,
    });
  }

  // Optional: critical email alert (Slack, PagerDuty)
  if (isCriticalTemplate(data.template)) {
    await sendSlackAlert({
      channel: "#email-alerts",
      message: `🚨 Critical email template failed: ${data.template}`,
      error: data.error,
    });
  }
}

function isCriticalTemplate(template: string): boolean {
  return ["password-reset", "email-verification", "magic-link"].includes(
    template
  );
}

Track render error rate

Monitor the percentage of emails that hit error boundaries. A sudden spike indicates a breaking change or bad deploy.

lib/email-metrics.ts
export async function trackEmailRender(
  template: string,
  success: boolean,
  renderTimeMs: number
) {
  // Send to your metrics backend
  await metrics.increment("email.renders.total", {
    template,
    status: success ? "success" : "error",
  });

  await metrics.histogram("email.render.duration_ms", renderTimeMs, {
    template,
  });

  // Alert if error rate exceeds threshold
  const errorRate = await getErrorRate(template, "1h");
  if (errorRate > 0.05) {
    // More than 5% failures
    await alertOnCall({
      severity: "high",
      message: `Email template ${template} error rate: ${errorRate * 100}%`,
    });
  }
}
Set up alerts for any email template with >2% render error rate. This catches issues before they impact significant user volume.

Production error boundary checklist

Before shipping email templates with error boundaries:

  • ✅ Wrap all email renders in EmailErrorBoundary
  • ✅ Provide fallback components for critical templates
  • ✅ Log render errors with structured data (template, recipient, props)
  • ✅ Test with malformed/missing props to verify fallback behavior
  • ✅ Set up monitoring and alerts for render error rate
  • ✅ Add plain text fallback as last resort for catastrophic failures
  • ✅ Document which templates use cached vs. runtime fallbacks

Common gotchas

Things that will bite you if you're not careful:

Error boundaries in SSR

React Email renders server-side, so error boundaries work slightly differently than in client apps. componentDidCatch still fires, but you won't have browser-specific error details.

Test error boundaries in the same environment you send emails from (Node.js, not browser).

Async errors aren't caught

Error boundaries only catch synchronous render errors. If you fetch data inside a template, those failures won't trigger the boundary.

emails/BrokenTemplate.tsx
// ❌ This won't be caught by error boundary
export function BrokenTemplate() {
  const [data, setData] = useState(null);

  useEffect(() => {
    fetch("/api/data")
      .then((res) => res.json())
      .then(setData)
      .catch((error) => {
        // Error boundary won't see this
        console.error(error);
      });
  }, []);

  return <div>{data?.name}</div>;
}

Solution: Fetch data before calling render() and pass it as props. Keep email templates synchronous.


Fallback components can fail too

Don't let your fallback component throw errors. Keep it absolutely minimal with no dependencies.

emails/fallbacks/UltraSafeFallback.tsx
// ✅ Can't fail - pure HTML with no dependencies
export function UltraSafeFallback({ resetToken }: { resetToken: string }) {
  return (
    <html>
      <body>
        <p>Password reset link:</p>
        <a href={`https://app.com/reset/${resetToken || "TOKEN_MISSING"}`}>
          Reset Password
        </a>
      </body>
    </html>
  );
}

Next steps

Error boundaries are defensive coding. They won't prevent bad data, but they'll contain the damage when it happens.

Recommended reading:

Want production-ready templates with error boundaries built in? Check out our SaaS template library — all templates include validation, fallbacks, and error handling.

Production-ready templates

Pick from 9 template packs built with React Email. One-time purchase, lifetime updates, tested across every major email client.

Browse all templates