Code Tips9 min read

Error Handling for React Email: Production Patterns That Prevent Silent Failures

Defensive error handling patterns for React Email: validation before render, retry logic, structured logging, text fallbacks, and user feedback that prevents silent failures.

R

React Emails Pro

February 27, 2026

Email errors are uniquely painful. When a button breaks, users complain. When an email fails to send — or worse, sends with missing data — users disappear without a trace. No error message, no stack trace, just silence.

This guide covers production error handling patterns for React Email: validation, retry logic, logging, fallbacks, and user feedback that prevents silent failures from reaching your inbox.

Why Email Errors Are Different

Unlike web UIs, email errors happen far from your application code:

  • No immediate feedback — users don't see a failed send until they check their inbox (and often assume it's in spam)
  • Silent data corruption — missing variables render as "undefined" or blank sections without throwing errors
  • Delayed failures — rate limits, temporary outages, and spam filtering happen asynchronously
  • Cross-client variability — emails that render perfectly in Gmail break in Outlook without warning

You need defensive patterns at every layer: validation before render, retries for transient failures, logging for debugging, and user feedback that doesn't promise what you can't guarantee.

Never assume an email was delivered just because resend.emails.send() returned successfully. That only confirms the email was accepted — not delivered, opened, or even rendered correctly.

Pattern 1: Validate Before Render

Catch bad data before it reaches your template. Use Zod schemas to validate all inputs:

lib/email-schemas.ts
import { z } from "zod";

export const emailBaseSchema = z.object({
  to: z.string().email("Invalid recipient email"),
  from: z.string().email("Invalid sender email"),
  replyTo: z.string().email().optional(),
});

export const welcomeEmailSchema = emailBaseSchema.extend({
  userName: z.string().min(1, "User name is required"),
  activationUrl: z.string().url("Invalid activation URL"),
  trialDays: z.number().int().positive(),
});

export const passwordResetSchema = emailBaseSchema.extend({
  resetUrl: z.string().url("Invalid reset URL"),
  expiresInMinutes: z.number().int().positive(),
  requestedFromIp: z.string().ip().optional(),
});

Then validate in your API route with clear error messages:

app/api/send-welcome/route.ts
import { welcomeEmailSchema } from "@/lib/email-schemas";
import { sendEmail } from "@/lib/email";
import { NextResponse } from "next/server";

export async function POST(request: Request) {
  try {
    const body = await request.json();
    
    // Validate before touching email logic
    const result = welcomeEmailSchema.safeParse(body);
    
    if (!result.success) {
      const errors = result.error.flatten().fieldErrors;
      console.error("[email] Validation failed:", errors);
      
      return NextResponse.json(
        { 
          error: "Invalid email data",
          details: errors 
        },
        { status: 400 }
      );
    }

    const { to, userName, activationUrl, trialDays } = result.data;

    await sendEmail("welcome", to, {
      userName,
      activationUrl,
      trialDays,
    });

    return NextResponse.json({ success: true });
  } catch (error) {
    console.error("[email] Unexpected error:", error);
    return NextResponse.json(
      { error: "Failed to send email" },
      { status: 500 }
    );
  }
}
Log validation errors separately from send failures. Validation errors mean your application logic is broken; send failures are usually transient.

Pattern 2: Smart Retry Logic

Email providers rate-limit, networks fail, and temporary outages happen. Build retry logic that handles transient failures without hammering the API:

lib/email-send.ts
import { Resend } from "resend";

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

type RetryConfig = {
  maxRetries: number;
  initialDelayMs: number;
  maxDelayMs: number;
};

const DEFAULT_RETRY_CONFIG: RetryConfig = {
  maxRetries: 3,
  initialDelayMs: 1000,
  maxDelayMs: 10000,
};

// Exponential backoff with jitter
async function sleep(ms: number) {
  const jitter = Math.random() * 0.3 * ms; // ±30% jitter
  await new Promise(resolve => setTimeout(resolve, ms + jitter));
}

// Determine if an error is retryable
function isRetryableError(error: unknown): boolean {
  if (error instanceof Error) {
    const message = error.message.toLowerCase();
    
    // Rate limits, timeouts, network errors
    if (
      message.includes("rate limit") ||
      message.includes("timeout") ||
      message.includes("econnreset") ||
      message.includes("enotfound") ||
      message.includes("temporarily unavailable")
    ) {
      return true;
    }
  }
  
  // Bad API key, invalid sender, etc. — don't retry
  return false;
}

export async function sendEmailWithRetry<T>(
  fn: () => Promise<T>,
  config: Partial<RetryConfig> = {}
): Promise<T> {
  const { maxRetries, initialDelayMs, maxDelayMs } = {
    ...DEFAULT_RETRY_CONFIG,
    ...config,
  };

  let lastError: unknown;

  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      return await fn();
    } catch (error) {
      lastError = error;

      // Don't retry on non-retryable errors
      if (!isRetryableError(error)) {
        throw error;
      }

      // Don't retry after the last attempt
      if (attempt === maxRetries) {
        break;
      }

      // Exponential backoff: 1s, 2s, 4s, 8s (capped at maxDelayMs)
      const delayMs = Math.min(
        initialDelayMs * Math.pow(2, attempt),
        maxDelayMs
      );

      console.warn(
        `[email] Attempt ${attempt + 1}/${maxRetries + 1} failed, retrying in ${delayMs}ms`,
        error
      );

      await sleep(delayMs);
    }
  }

  console.error(`[email] All ${maxRetries + 1} attempts failed`, lastError);
  throw lastError;
}

Use the retry wrapper in your send function:

lib/email.ts
import { sendEmailWithRetry } from "./email-send";

export async function sendEmail(
  template: string,
  to: string,
  props: unknown
) {
  return sendEmailWithRetry(async () => {
    const result = await resend.emails.send({
      from: "App <hello@yourapp.com>",
      to,
      subject: getSubject(template, props),
      react: renderTemplate(template, props),
    });

    if (result.error) {
      throw new Error(`Resend error: ${result.error.message}`);
    }

    return result.data;
  });
}
Retries work for rate limits and network errors. For permanent failures (invalid sender domain, no API key), retries just waste time — fail fast instead.

Recommended

SaaS Essentials Pack

21+ Templates · 60+ Variations. One-time purchase, lifetime updates.

$19.95$9.95Get it

Pattern 3: Structured Logging

When emails fail in production, you need context to debug. Log every step with structured data:

lib/email-logger.ts
type EmailLogContext = {
  template: string;
  recipient: string;
  attempt?: number;
  duration?: number;
  error?: unknown;
};

export function logEmailEvent(
  event: "validation_failed" | "send_started" | "send_success" | "send_failed",
  context: EmailLogContext
) {
  const logData = {
    timestamp: new Date().toISOString(),
    event,
    ...context,
  };

  switch (event) {
    case "validation_failed":
    case "send_failed":
      console.error("[email]", logData);
      break;
    case "send_success":
      console.info("[email]", logData);
      break;
    case "send_started":
      console.debug("[email]", logData);
      break;
  }

  // Send to observability platform (Sentry, DataDog, etc.)
  if (process.env.NODE_ENV === "production") {
    // Example: track errors in Sentry
    if (event === "send_failed" && context.error) {
      // Sentry.captureException(context.error, { contexts: { email: logData } });
    }
  }
}

Integrate logging into your send function:

lib/email.ts
import { logEmailEvent } from "./email-logger";

export async function sendEmail(
  template: string,
  to: string,
  props: unknown
) {
  const startTime = Date.now();

  logEmailEvent("send_started", { template, recipient: to });

  try {
    const result = await sendEmailWithRetry(async () => {
      const result = await resend.emails.send({
        from: "App <hello@yourapp.com>",
        to,
        subject: getSubject(template, props),
        react: renderTemplate(template, props),
      });

      if (result.error) {
        throw new Error(`Resend error: ${result.error.message}`);
      }

      return result.data;
    });

    logEmailEvent("send_success", {
      template,
      recipient: to,
      duration: Date.now() - startTime,
    });

    return result;
  } catch (error) {
    logEmailEvent("send_failed", {
      template,
      recipient: to,
      duration: Date.now() - startTime,
      error,
    });

    throw error;
  }
}

Pattern 4: Text Fallback

When HTML rendering breaks (old email clients, accessibility tools), provide a clean plain-text fallback:

emails/welcome.tsx
import { render } from "@react-email/render";
import WelcomeEmailHTML from "./welcome-html";

export async function renderWelcomeEmail(props: WelcomeEmailProps) {
  const html = render(<WelcomeEmailHTML {...props} />);
  
  // Generate plain text fallback
  const text = `
Welcome to App, ${props.userName}!

Get started by activating your account:
${props.activationUrl}

Your trial expires in ${props.trialDays} days.

Need help? Reply to this email.

— The App Team
  `.trim();

  return { html, text };
}

Include both versions when sending:

const { html, text } = await renderWelcomeEmail(props);

await resend.emails.send({
  from: "App <hello@yourapp.com>",
  to: email,
  subject: "Welcome to App",
  html,
  text,
});
Some spam filters penalize emails with no text version. Always include one, even if most users see the HTML.

Pattern 5: User Feedback Patterns

Never tell users "Check your email" unless you're confident it will arrive. Use conditional messaging based on send success:

components/email-sent-feedback.tsx
type FeedbackProps = {
  status: "success" | "failed" | "pending";
  email: string;
};

export function EmailSentFeedback({ status, email }: FeedbackProps) {
  if (status === "pending") {
    return <div>Sending email...</div>;
  }

  if (status === "failed") {
    return (
      <div className="error">
        <p>
          <strong>We couldn't send the email.</strong>
        </p>
        <p>
          Please check your email address ({email}) and try again. If the
          problem continues, contact support.
        </p>
      </div>
    );
  }

  // Success — but don't over-promise
  return (
    <div className="success">
      <p>
        <strong>Email sent to {email}</strong>
      </p>
      <p>
        If you don't see it in a few minutes, check your spam folder or{" "}
        <button onClick={resendEmail}>send it again</button>.
      </p>
    </div>
  );
}

This pattern acknowledges that delivery isn't guaranteed and gives users a recovery path when things go wrong.

Error Handling Checklist

Use this checklist to audit your email sending logic:

  • All email inputs validated with Zod before rendering
  • Retry logic implemented for transient failures (rate limits, network errors)
  • Non-retryable errors (bad API key, invalid sender) fail fast
  • Structured logs capture template, recipient, duration, and errors
  • Plain text fallback included with every HTML email
  • User feedback reflects actual send status (don't promise delivery)
  • Critical emails (password reset, verification) trigger alerts on repeated failures

With these patterns, you catch errors early, recover from transient failures, and have the logs you need to debug issues in production. No more silent failures.

Production-ready templates for every flow

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

Browse all templates