name: resend-integration description: Load when integrating Resend for transactional email with React Email templates. Applies when implementing email sending, webhook handling, domain verification, or batch email operations.
When This Rule Applies
Apply when implementing transactional email, marketing email, or any email sending with Resend.
React Email Templates
Template Component
// emails/order-confirmation.tsx
import { Html, Container, Heading, Text, Button } from '@react-email/components';
interface OrderProps {
orderNumber: string;
customerName: string;
totalAmount: number;
}
export function OrderConfirmationEmail({ orderNumber, customerName, totalAmount }: OrderProps) {
return (
<Html lang="en">
<Container style={{ maxWidth: '600px', margin: '0 auto' }}>
<Heading>Thank you, {customerName}!</Heading>
<Text>Order #{orderNumber} confirmed.</Text>
<Text style={{ fontWeight: 'bold' }}>Total: ${totalAmount.toFixed(2)}</Text>
<Button href={`https://yourapp.com/orders/${orderNumber}`}>
View Order
</Button>
</Container>
</Html>
);
}
Sending with React Component
import { Resend } from 'resend';
import { OrderConfirmationEmail } from './emails/order-confirmation';
const resend = new Resend(process.env.RESEND_API_KEY);
await resend.emails.send({
from: 'orders@yourdomain.com',
to: customerEmail,
subject: `Order Confirmation: #${orderNumber}`,
react: <OrderConfirmationEmail {...props} />,
});
Webhook Handling
Next.js Webhook Endpoint
// app/api/webhooks/resend/route.ts
import { NextRequest, NextResponse } from 'next/server';
import crypto from 'crypto';
export async function POST(request: NextRequest) {
const signature = request.headers.get('x-resend-signature');
const rawBody = await request.text();
// Validate signature
if (!validateSignature(rawBody, signature!, process.env.RESEND_WEBHOOK_SECRET!)) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
}
const payload = JSON.parse(rawBody);
switch (payload.type) {
case 'email.delivered':
await handleDelivered(payload.data);
break;
case 'email.bounced':
await handleBounce(payload.data); // Remove from mailing list
break;
case 'email.complained':
await handleComplaint(payload.data); // Unsubscribe user
break;
}
return NextResponse.json({ success: true });
}
function validateSignature(payload: string, signature: string, secret: string): boolean {
const hash = crypto.createHmac('sha256', secret).update(payload).digest('hex');
return crypto.timingSafeEqual(Buffer.from(hash), Buffer.from(signature));
}
Webhook Event Types
| Event | When | Action |
|---|---|---|
email.sent |
API request successful | Log attempt |
email.delivered |
Reached recipient server | Mark delivered |
email.bounced |
Delivery failed | Remove from list |
email.complained |
Marked as spam | Unsubscribe |
email.delivery_delayed |
Temporary issue | Monitor |
Error Handling & Retries
Retry with Exponential Backoff
async function sendEmailWithRetry(params: EmailParams, maxAttempts = 5) {
let delay = 500;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await resend.emails.send(params);
} catch (error) {
if (attempt === maxAttempts) throw error;
// Don't retry auth/validation errors
if (isNonRetriable(error)) throw error;
const jitter = Math.random() * delay * 0.1;
await sleep(delay + jitter);
delay = Math.min(delay * 2, 30000);
}
}
}
Idempotency Keys (Prevent Duplicates)
// Use deterministic key based on event + entity
const idempotencyKey = `order-confirmation/${orderId}`;
await resend.emails.send(
{ from, to, subject, react: <Template /> },
{ idempotencyKey }
);
// Same key + same payload = no duplicate send
// Same key + different payload = error (intentional)
Domain Verification
Required DNS Records
| Record | Type | Name | Value |
|---|---|---|---|
| SPF | TXT | send | v=spf1 include:amazonses.com ~all |
| DKIM | TXT | resend._domainkey | v=DKIM1; h=sha256; p=... |
| MX | MX | inbound | inbound-smtp.us-east-1.amazonaws.com |
Best Practice: Use Subdomains
// Use notifications.yourdomain.com instead of yourdomain.com
// - Isolates sending reputation
// - Makes intent clear to recipients
// - Reduces impact of deliverability issues
Disable Tracking for Transactional Emails
Open/click tracking can trigger spam filters. Disable for critical emails:
await resend.domains.create({
name: 'notifications.yourdomain.com',
// Disable tracking for better deliverability
openTracking: false,
clickTracking: false,
});
Batch Sending
Send Up to 100 Emails Per Request
const emails = recipients.map(recipient => ({
from: 'marketing@yourdomain.com',
to: recipient.email,
subject: 'Newsletter',
html: generateHTML(recipient.name),
}));
// Batch send (100 max per call)
const response = await resend.batch.send(emails);
Rate Limiting
Default: 2 requests/second. Check headers:
// Response headers
'ratelimit-limit': 2
'ratelimit-remaining': 1
'ratelimit-reset': 5
'retry-after': 5 // When rate limited
Common Gotchas
Webhook Duplicates
Resend uses "at least once" delivery. Implement idempotency:
const processed = await db.findEvent(webhookId);
if (processed) return; // Skip duplicate
await processWebhook(payload);
await db.markProcessed(webhookId);
60-Second Webhook Timeout
Return 200 immediately, process async:
await jobQueue.enqueue('processEmail', payload);
return NextResponse.json({ success: true }); // Return fast
Domain Not Verified
- DNS propagation takes up to 24 hours
- Use exact record values from Resend dashboard
- Check for typos in record names
Emails Going to Spam
- Enable SPF + DKIM (both required)
- Disable tracking for transactional emails
- Use consistent "from" address
- Warm up new domains gradually
Quick Reference
| Task | Pattern |
|---|---|
| Send with React template | react: <Component /> |
| Prevent duplicates | { idempotencyKey } option |
| Batch send | resend.batch.send(emails) |
| Validate webhooks | Check x-resend-signature header |
| Handle bounces | Webhook → remove from list |