Resend: Transactional Email Done Right in 2026
Production-ready patterns for Resend v3+. Real errors, exact setup, and patterns that actually scale for indie hackers.
TL;DR
Resend handles transactional email without the DevOps nightmare. We'll cover v3+ setup, production patterns, common errors, and why it beats the alternatives for indie projects.
---
Why Resend Matters
Transactional email is boring infrastructure that shouldn't consume your engineering time. Most teams either:
1. Use AWS SES and spend 3 weeks debugging deliverability 2. Use SendGrid and get a $300/month bill for 10k emails 3. Use Gmail SMTP and watch it rate-limit at 2am
Resend eliminates this. It's built for developers shipping fast. The API is minimal, the docs are honest, and the pricing doesn't punish scale.
Verify current pricing and limits in [official Resend docs](https://resend.com/docs) - but the general structure: $0 setup, pay-per-email at volume.
---
Setup (v3.x)
```bash npm install resend@latest
Or verify version: npm view resend versions
```Grab your API key from [Resend dashboard](https://resend.com/api-keys). Create .env.local:
```env RESEND_API_KEY=re_xxxxxxxxxxxxx ```
---
Production Pattern: Typed Email Templates
Here's a pattern that scales without becoming spaghetti:
```typescript // lib/email/templates.ts import { Resend } from 'resend';
const resend = new Resend(process.env.RESEND_API_KEY);
// Type-safe email definitions interface EmailTemplate<T> { subject: string; template: (data: T) => string; from?: string; }
const templates = { welcome: { subject: 'Welcome to our platform', template: (data: { name: string; confirmUrl: string }) => ` <h1>Welcome, ${data.name}!</h1> <p><a href="${data.confirmUrl}">Confirm your email</a></p> `, from: 'onboarding@example.com', } as EmailTemplate<{ name: string; confirmUrl: string }>,
passwordReset: { subject: 'Reset your password', template: (data: { resetUrl: string; expiresIn: string }) => ` <p><a href="${data.resetUrl}">Reset password</a></p> <p>Link expires in ${data.expiresIn}.</p> `, from: 'security@example.com', } as EmailTemplate<{ resetUrl: string; expiresIn: string }>, };
export default templates; ```
Now create a sender wrapper with retry logic:
```typescript // lib/email/send.ts import { Resend } from 'resend'; import templates from './templates';
const resend = new Resend(process.env.RESEND_API_KEY);
interface SendEmailOptions { templateKey: keyof typeof templates; to: string | string[]; data: Record<string, unknown>; replyTo?: string; }
export async function sendEmail({ templateKey, to, data, replyTo, }: SendEmailOptions) { const template = templates[templateKey];
if (!template) {
throw new Error(Template "${templateKey}" not found);
}
const toArray = Array.isArray(to) ? to : [to]; const retries = 3; let lastError: Error | null = null;
for (let attempt = 1; attempt <= retries; attempt++) { try { const response = await resend.emails.send({ from: template.from || 'noreply@example.com', to: toArray, subject: template.subject, html: template.template(data as never), replyTo, });
if (response.error) {
throw new Error(Resend API error: ${response.error.message});
}
console.log([Email] Sent "${templateKey}" to ${toArray.join(',')});
return response.data;
} catch (error) {
lastError = error as Error;
const isLastAttempt = attempt === retries;
if (isLastAttempt) {
console.error(
[Email] Failed after ${retries} attempts:,
lastError.message
);
throw lastError;
}
// Exponential backoff: 1s, 2s, 4s const delayMs = Math.pow(2, attempt - 1) * 1000; await new Promise((resolve) => setTimeout(resolve, delayMs)); } }
throw lastError || new Error('Email send failed'); } ```
Usage in your route or action:
```typescript // app/api/auth/signup/route.ts import { sendEmail } from '@/lib/email/send';
export async function POST(req: Request) { const { email, name } = await req.json();
// ... validation and user creation ...
try {
await sendEmail({
templateKey: 'welcome',
to: email,
data: {
name,
confirmUrl: ${process.env.NEXT_PUBLIC_BASE_URL}/confirm?token=xyz,
},
});
} catch (error) {
console.error('Welcome email failed:', error);
// Don't fail signup - queue for retry or alert ops
}
return Response.json({ success: true }); } ```
---
Real Errors You'll See
Error 1: Missing Domain Verification
``` Error: Invalid 'from' address. Please add and verify the domain in Resend dashboard. ```
Fix: Go to Resend dashboard → Domains → Add domain → Verify DNS records. Takes 5-10 minutes.
Error 2: Invalid Email Addresses
``` Error: Invalid email address format for recipient ```
Fix: Sanitize emails before sending:
```typescript function isValidEmail(email: string): boolean { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); }
if (!isValidEmail(to)) {
throw new Error(Invalid email: ${to});
}
```
Error 3: Rate Limiting (v3.x behavior)
``` Error: Too many requests. Please retry after 60 seconds. ```
Fix: Implement queue-based sending for bulk operations, or use Resend's batch endpoint. For individual transactional emails, this is rare. If you hit it, you have bigger growth problems—good problems.
---
Production Checklist
.env.local (never .env)response.id for debugging)---
Alternatives (Why Resend Wins)
| Service | Setup Time | Price | Developer Experience | |---------|-----------|-------|----------------------| | Resend | 15 min | Pay-per-email | 🟢 Excellent | | SendGrid | 30 min | $20+ | 🟡 Good but heavier | | AWS SES | 2 hours | Cheap but complex | 🔴 Painful | | Mailgun | 45 min | $20+ | 🟡 Good for power users |
For indie hackers: Resend's simplicity + reliability combo is unbeatable.
---
Resources
---
What am I missing?
Please comment with:
Accuracies matter here—if pricing or version details changed, flag it and I'll update. This is indie developer infrastructure; we're all shipping together.