React Email11 min read

React Email + API Routes: Production Patterns for Reliable Sends

Build production-ready email API routes with React Email: validation, error handling, idempotency, rate limiting, async patterns, and structured responses that prevent silent failures.

R

React Emails Pro

February 28, 2026

You've built your React Email template. It looks great in preview. Now you need to actually send it.

Most teams write a quick API route, ship it, and then spend the next six months debugging why password resets randomly fail or why users report "I never got the email."

This post covers the production patterns you need before shipping email sends: validation, error handling, idempotency, rate limiting, and structured responses.

The basic setup (but don't ship this)

Here's what most tutorials show:

app/api/send/route.ts
import { resend } from '@/lib/resend';
import { WelcomeEmail } from '@/emails/welcome';

export async function POST(req: Request) {
  const { email } = await req.json();
  
  await resend.emails.send({
    from: 'onboarding@yourapp.com',
    to: email,
    subject: 'Welcome to Your App',
    react: WelcomeEmail({ email }),
  });
  
  return Response.json({ success: true });
}

This works. Once. Then it breaks in production because:

  • No validation (what if email is missing or malformed?)
  • No error handling (network failure? rate limit? invalid recipient?)
  • No idempotency (user clicks "resend verification" 3 times → 3 emails)
  • No logging (when users say "I didn't get it," you have no context)

Step 1: Validate before you send

Don't trust client data. Validate the payload before you attempt to send.

app/api/send-welcome/route.ts
import { z } from 'zod';
import { resend } from '@/lib/resend';
import { WelcomeEmail } from '@/emails/welcome';

const SendWelcomeSchema = z.object({
  email: z.string().email('Invalid email address'),
  userId: z.string().min(1),
  name: z.string().optional(),
});

export async function POST(req: Request) {
  try {
    const body = await req.json();
    const validated = SendWelcomeSchema.parse(body);
    
    const { data, error } = await resend.emails.send({
      from: 'onboarding@yourapp.com',
      to: validated.email,
      subject: 'Welcome to Your App',
      react: WelcomeEmail({ 
        email: validated.email, 
        name: validated.name 
      }),
    });
    
    if (error) {
      console.error('[send-welcome] Resend error:', error);
      return Response.json({ error: 'Failed to send email' }, { status: 500 });
    }
    
    return Response.json({ success: true, emailId: data?.id });
  } catch (err) {
    if (err instanceof z.ZodError) {
      return Response.json({ error: err.errors }, { status: 400 });
    }
    console.error('[send-welcome] Unexpected error:', err);
    return Response.json({ error: 'Internal error' }, { status: 500 });
  }
}
Use Zod schemas that match your email template props. If the schema passes, the template won't blow up mid-render.

Step 2: Add idempotency for critical emails

Password resets, verification emails, and magic links should be idempotent: sending the same request twice should not create duplicate emails.

Two strategies:

1) Use an idempotency key (client-controlled)

app/api/send-reset/route.ts
import { resend } from '@/lib/resend';
import { redis } from '@/lib/redis'; // or any KV store
import { PasswordResetEmail } from '@/emails/password-reset';

export async function POST(req: Request) {
  const idempotencyKey = req.headers.get('idempotency-key');
  
  if (!idempotencyKey) {
    return Response.json({ error: 'Missing idempotency-key header' }, { status: 400 });
  }
  
  // Check if we've already processed this request
  const cached = await redis.get(`email:idempotency:${idempotencyKey}`);
  if (cached) {
    return Response.json(JSON.parse(cached));
  }
  
  const { email, resetToken } = await req.json();
  
  const { data, error } = await resend.emails.send({
    from: 'security@yourapp.com',
    to: email,
    subject: 'Reset your password',
    react: PasswordResetEmail({ resetToken }),
  });
  
  if (error) {
    return Response.json({ error: 'Failed to send' }, { status: 500 });
  }
  
  const response = { success: true, emailId: data?.id };
  
  // Cache response for 5 minutes
  await redis.setex(
    `email:idempotency:${idempotencyKey}`,
    300,
    JSON.stringify(response)
  );
  
  return Response.json(response);
}

2) Server-side deduplication (user + intent)

If you control the trigger (e.g., "send verification"), dedupe based on user + email type + time window:

lib/send-with-dedup.ts
import { resend } from '@/lib/resend';
import { redis } from '@/lib/redis';

export async function sendWithDedup({
  userId,
  emailType,
  windowSeconds = 60,
  ...emailOptions
}: {
  userId: string;
  emailType: string;
  windowSeconds?: number;
  from: string;
  to: string;
  subject: string;
  react: React.ReactElement;
}) {
  const dedupKey = `email:dedup:${userId}:${emailType}`;
  const existing = await redis.get(dedupKey);
  
  if (existing) {
    return { skipped: true, reason: 'Recently sent' };
  }
  
  const { data, error } = await resend.emails.send(emailOptions);
  
  if (!error) {
    await redis.setex(dedupKey, windowSeconds, data?.id || 'sent');
  }
  
  return { data, error };
}
Don't dedupe marketing emails or receipts. Only use this for security and activation flows where duplicate sends are genuinely a problem.

Step 3: Handle errors properly

Email sends fail. API limits, invalid recipients, network timeouts. You need to log the context and return actionable errors to the client.

app/api/send-verification/route.ts
import { resend } from '@/lib/resend';
import { db } from '@/lib/db';
import { VerificationEmail } from '@/emails/verification';

export async function POST(req: Request) {
  try {
    const { email, userId } = await req.json();
    
    const { data, error } = await resend.emails.send({
      from: 'verify@yourapp.com',
      to: email,
      subject: 'Verify your email address',
      react: VerificationEmail({ email, verificationUrl: '...' }),
    });
    
    if (error) {
      // Log structured error for debugging
      await db.emailLog.create({
        data: {
          userId,
          email,
          type: 'verification',
          status: 'failed',
          error: JSON.stringify(error),
          timestamp: new Date(),
        },
      });
      
      // Return user-friendly error
      if (error.message?.includes('rate limit')) {
        return Response.json(
          { error: 'Too many requests. Try again in a few minutes.' },
          { status: 429 }
        );
      }
      
      if (error.message?.includes('invalid')) {
        return Response.json(
          { error: 'Invalid email address.' },
          { status: 400 }
        );
      }
      
      return Response.json(
        { error: 'Failed to send verification email.' },
        { status: 500 }
      );
    }
    
    // Log success
    await db.emailLog.create({
      data: {
        userId,
        email,
        type: 'verification',
        status: 'sent',
        emailId: data?.id,
        timestamp: new Date(),
      },
    });
    
    return Response.json({ success: true });
  } catch (err) {
    console.error('[send-verification] Unexpected error:', err);
    return Response.json({ error: 'Internal error' }, { status: 500 });
  }
}
Log every send attempt. When a user says "I didn't get the email," you need to know: did it send? did it fail? was it rate limited?

Step 4: Add rate limiting

Users will spam "resend verification" when it doesn't arrive immediately. Protect your send limits and avoid triggering provider rate limits.

lib/rate-limit.ts
import { redis } from '@/lib/redis';

export async function checkRateLimit(
  key: string,
  limit: number,
  windowSeconds: number
): Promise<{ allowed: boolean; remaining: number }> {
  const current = await redis.incr(key);
  
  if (current === 1) {
    await redis.expire(key, windowSeconds);
  }
  
  const allowed = current <= limit;
  const remaining = Math.max(0, limit - current);
  
  return { allowed, remaining };
}

// Usage in API route:
export async function POST(req: Request) {
  const { email } = await req.json();
  
  const { allowed, remaining } = await checkRateLimit(
    `email:rate:${email}`,
    3, // 3 emails
    60 // per minute
  );
  
  if (!allowed) {
    return Response.json(
      { error: 'Rate limit exceeded. Try again later.' },
      { status: 429, headers: { 'X-RateLimit-Remaining': remaining.toString() } }
    );
  }
  
  // ... send email
}

Recommended limits for transactional emails:

  • Verification emails: 3 per user per hour
  • Password resets: 5 per user per hour
  • Magic links: 10 per user per hour (more generous for auth flows)

Step 5: Consider async patterns for non-blocking sends

Some emails don't need to block the response. Move them to a queue or background job.

When to send async:

  • Welcome emails (user already activated)
  • Receipts (payment already confirmed)
  • Newsletters, digests, notifications

When to send synchronously:

  • Verification emails (user is waiting for it to proceed)
  • Password resets (time-sensitive)
  • Magic links (user expects immediate delivery)
lib/queue.ts
// Example with Inngest (or BullMQ, Trigger.dev, etc.)
import { inngest } from '@/lib/inngest';
import { resend } from '@/lib/resend';

export const sendWelcomeEmail = inngest.createFunction(
  { id: 'send-welcome-email' },
  { event: 'user/signed-up' },
  async ({ event }) => {
    const { email, name } = event.data;
    
    await resend.emails.send({
      from: 'onboarding@yourapp.com',
      to: email,
      subject: 'Welcome!',
      react: WelcomeEmail({ email, name }),
    });
  }
);

// Trigger from your API route:
await inngest.send({
  name: 'user/signed-up',
  data: { email, name },
});
Async sends give you retry logic, observability, and don't block your API response. But only use them when the user doesn't need confirmation that the email sent.

Step 6: Return structured responses

Your API should return consistent, actionable responses — especially when things fail.

lib/email-response.ts
type EmailResponse =
  | { success: true; emailId: string }
  | { success: false; error: string; code: string };

export function emailSuccess(emailId: string): EmailResponse {
  return { success: true, emailId };
}

export function emailError(code: string, message: string): EmailResponse {
  return { success: false, error: message, code };
}

// Usage:
if (error) {
  if (error.message?.includes('rate limit')) {
    return Response.json(emailError('RATE_LIMITED', 'Too many requests'), { status: 429 });
  }
  return Response.json(emailError('SEND_FAILED', 'Failed to send email'), { status: 500 });
}

return Response.json(emailSuccess(data?.id || ''));

Clients can now handle errors explicitly:

components/resend-verification-button.tsx
async function handleResend() {
  const res = await fetch('/api/send-verification', {
    method: 'POST',
    body: JSON.stringify({ email }),
  });
  
  const json = await res.json();
  
  if (!json.success) {
    if (json.code === 'RATE_LIMITED') {
      toast.error('Please wait a few minutes before trying again.');
    } else {
      toast.error('Failed to send. Contact support if this persists.');
    }
    return;
  }
  
  toast.success('Verification email sent!');
}

Production checklist

Before you ship an email-sending API route:

  • ✓ Validate input with Zod (or equivalent)
  • ✓ Handle errors and return user-friendly messages
  • ✓ Log all send attempts (success and failure)
  • ✓ Add idempotency for security/activation flows
  • ✓ Rate limit per user or per email
  • ✓ Return structured responses with error codes
  • ✓ Test failure cases (invalid email, rate limit, network error)

What to read next

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