Resend Transactional Email Done Right in 2026
Production patterns for Resend v3+ with real error handling, exact code examples, and avoiding common pitfalls developers hit.
TL;DR
Resend (v3+) strips away SMTP complexity for transactional email. We'll cover production patterns, real errors you'll hit, and why it's genuinely better than managing your own queue. Verify pricing on [official docs](https://resend.com/docs) as rates evolve.Why Transactional Email Matters More Than You Think
Transactional email—password resets, order confirmations, account notifications—sits in a weird space. It's mission-critical but easy to deprioritize. Most solutions either lock you into a platform (Mailchimp) or dump raw SMTP on you (SendGrid's older patterns). Resend takes a different angle: treat email like API calls, not batch jobs.
The core insight: if you're already writing Node.js/Next.js, why context-switch to SMTP concepts? Resend's TypeScript-first design means fewer mental overhead and fewer config files.
Setting Up Resend (v3.x)
First, grab your API key from the [Resend dashboard](https://dashboard.resend.com). Then install:
```bash npm install resend@latest ```
Verify your version matches v3.x by checking package.json—earlier v2.x APIs differ in error handling.
Basic setup in your app:
```typescript import { Resend } from 'resend';
const resend = new Resend(process.env.RESEND_API_KEY);
export const sendWelcomeEmail = async (userEmail: string, userName: string) => {
try {
const response = await resend.emails.send({
from: 'onboarding@yourdomain.com',
to: userEmail,
subject: 'Welcome to our platform',
html: <h1>Hi ${userName}</h1><p>You're all set.</p>,
});
if (response.error) {
console.error('Resend error:', response.error);
throw new Error(Email send failed: ${response.error.message});
}
return { success: true, id: response.data?.id }; } catch (err) { console.error('Unexpected error:', err); throw err; } }; ```
Critical detail: Resend returns both error and data objects. You must check if (response.error) before assuming success. This catches validation failures without throwing.
Real Errors You'll Hit (And How to Handle Them)
Error 1: Missing from Domain Configuration
```
ResendError: Invalid from address.
Make sure you have verified the domain and
are using a valid email from that domain.
```You must verify the domain in the Resend dashboard *and* own DNS records. If testing locally, use onboarding@resend.dev (Resend's sandbox):
```typescript const from = process.env.NODE_ENV === 'production' ? 'noreply@yourdomain.com' : 'onboarding@resend.dev'; ```
Error 2: Rate Limit (100 emails/second)
``` ResendError: Rate limit exceeded. Maximum 100 emails per second. ```This crops up in mass operations. Implement backoff:
```typescript const sendWithRetry = async (email: string, maxRetries = 3) => { for (let attempt = 0; attempt < maxRetries; attempt++) { const response = await resend.emails.send({ from: 'noreply@yourdomain.com', to: email, subject: 'Test', html: '<p>Test</p>', });
if (!response.error) return response.data;
if (response.error.message?.includes('Rate limit')) { const delay = Math.pow(2, attempt) * 1000; // exponential backoff await new Promise(r => setTimeout(r, delay)); continue; }
throw response.error; } }; ```
Error 3: Invalid Email Format
``` ResendError: Invalid recipient address. ```Validate before sending:
```typescript const isValidEmail = (email: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
if (!isValidEmail(userEmail)) {
throw new Error(Invalid email format: ${userEmail});
}
```
React Email Integration (The Production Pattern)
Resend pairs beautifully with [React Email](https://react.email)—write email templates as JSX. This prevents HTML escaping nightmares:
```typescript import { render } from '@react-email/render'; import WelcomeTemplate from './emails/WelcomeTemplate';
const WelcomeTemplate = ({ userName }: { userName: string }) => (
<div style={{ fontFamily: 'Arial' }}>
<h1>Welcome, {userName}!</h1>
<p>Confirm your account below:</p>
<a
href={${process.env.NEXT_PUBLIC_APP_URL}/verify?token=...}
style={{
backgroundColor: '#007bff',
color: 'white',
padding: '10px 20px',
textDecoration: 'none',
borderRadius: '4px',
}}
>
Verify Email
</a>
</div>
);
export const sendWelcome = async (email: string, userName: string) => { const html = render(<WelcomeTemplate userName={userName} />);
return resend.emails.send({ from: 'verify@yourdomain.com', to: email, subject: 'Verify your email', html, }); }; ```
Handling Failures in Production
Email failures shouldn't crash your app. Queue failed sends:
```typescript import { Ratelimit } from '@upstash/ratelimit'; import { Redis } from '@upstash/redis';
const ratelimit = new Ratelimit({ redis: Redis.fromEnv(), limiter: Ratelimit.slidingWindow(100, '1 s'), });
export const queueEmail = async (userEmail: string, templateId: string) => { const { success } = await ratelimit.limit(userEmail); if (!success) { // Queue in database/Redis for retry await db.emailQueue.create({ to: userEmail, templateId, status: 'pending', retries: 0, }); return { queued: true }; }
const response = await resend.emails.send({ from: 'noreply@yourdomain.com', to: userEmail, subject: 'Your notification', html: '<p>Content</p>', });
return response; }; ```
Key Production Checklist
1. Domain verification: Add SPF, DKIM, DMARC records ([verify in official docs](https://resend.com/docs/dashboard/settings)) 2. Environment variables: Never hardcode API keys 3. Error monitoring: Log failures to Sentry or similar 4. Unsubscribe headers: Required by CAN-SPAM 5. Send rate: Monitor your 100/sec limit during peaks
Costs & Pricing
Resend charges per email sent. Verify current pricing on [official docs](https://resend.com/pricing) as rates may have changed since publication. Most indie projects stay free tier; serious volume gets reasonable commercial rates.
Common Patterns to Copy
Check our guide on [email validation patterns](/?guide=email-validation) and [async job queuing](/?guide=background-jobs) for complementary strategies.
What am I missing?
Have you hit errors we didn't cover? Using Resend with specific frameworks (Rails, Python, etc.)? Found workarounds for the 100/sec limit? Drop corrections and real-world patterns in the comments—this guide improves with community input.