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.
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:
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:
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 }
);
}
}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:
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:
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;
});
}Recommended
SaaS Essentials Pack
21+ Templates · 60+ Variations. One-time purchase, lifetime updates.
Pattern 3: Structured Logging
When emails fail in production, you need context to debug. Log every step with structured data:
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:
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:
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,
});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:
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.