Resend, an API for transactional email
Resend is a service for sending messages from an application: signup confirmations, password resets, order notifications. What sets it apart is that templates are written as React components rather than as HTML built from tables. The client library ships under MIT, at version 6.18.1 as of writing.
What you are actually buying
Two things are worth separating, because newcomers conflate them and they cost different money while solving different problems.
The first is sending a message at all. Technically you can do that from your own server in a dozen lines of code, with no service required. The second is getting that message into an inbox rather than a spam folder, and that is a problem of an entirely different class.
The second part is what you pay for. The service maintains a pool of addresses with good reputation, keeps them off blocklists, handles feedback loops with large mail providers, and filters out addresses that bounce. A mail server you stand up yourself on a cloud machine starts from an address with no history, and often from a range that large providers treat with suspicion by default.
The practical conclusion is straightforward: sending it yourself makes sense for internal mail, where the recipient will add you to their trusted senders anyway. For anything reaching a customer, the service costs less than one day spent investigating why password resets never arrive.
React templates, the defining feature
HTML in email is its own discipline and one of the few where rules from the nineteen nineties still apply. Layout is built from tables, styles go inline, and every mail client interprets it slightly differently. Writing that by hand is unpleasant, and maintaining it through a rebrand is worse.
The React Email library, built by the same team, lets you write a message as a component and then generate HTML that respects all those constraints. You write normally, in the syntax you know from the application, and the compilation layer deals with tables and inline styles.
The gain is largest where there are many messages sharing elements. A header, a footer, a button, and a colour scheme are extracted once and used across twenty templates, exactly as in React on the application side. Changing a logo stops being a search across twenty HTML files.
It is worth knowing this does not lift the medium's own limits. There is still no scripting, some mail clients still ignore some style rules, and you still have to test across several clients. The library makes writing easier; it does not change the rules of the game.
Pricing
Billing runs on two axes, and that is the first thing that surprises people planning a budget. Transactional mail is billed by messages sent, while marketing mail is billed by contacts on a list.
| Transactional plan | Monthly price | Messages | Domains |
|---|---|---|---|
| Free | 0 USD | 3,000 | 1 |
| Pro | from 20 USD | 50,000 | 10 |
| Pro | 35 USD | 100,000 | 10 |
| Scale | from 90 USD | 100,000 | 1,000 |
| Scale | 350 USD | 500,000 | 1,000 |
| Scale | 650 USD | 1,000,000 | 1,000 |
The marketing track is separate and counts list size rather than sends. The free plan covers a thousand contacts; the paid tier starts at 40 dollars a month for five thousand and rises to 650 dollars at a hundred and fifty thousand.
Two things are worth noticing when comparing plans. The free plan's domain limit is one, which rules it out across several projects sooner than the message limit does. Log retention is thirty days on every plan, so if you need longer history for accounting purposes you have to record it on your own side.
Two further items sit outside the table and can decide the choice. The free plan carries a separate daily cap of a hundred messages, so three thousand a month does not mean you can send them in one day. With notifications going out in waves, after an overnight batch for instance, that cap bites before the monthly one does.
The second is overage billing. Above the volume included in a plan you pay between 46 and 90 cents per thousand messages, depending on tier. With traffic that jumps between months, work out whether overage on a lower plan costs less than moving up to the next one with headroom.
Installation and configuration
Package installation
# Main Resend package
npm install resend
# React Email for templates (optional, but recommended)
npm install @react-email/components
# For previewing templates locally
npm install react-email --save-devAPI key configuration
Create an account at resend.com and generate an API key:
// lib/resend.ts
import { Resend } from 'resend'
if (!process.env.RESEND_API_KEY) {
throw new Error('Missing RESEND_API_KEY environment variable')
}
export const resend = new Resend(process.env.RESEND_API_KEY)# .env.local
RESEND_API_KEY=re_xxxxxxxxxxxxxBasic email sending
Simple text email
import { resend } from '@/lib/resend'
const { data, error } = await resend.emails.send({
from: 'hello@yourdomain.com',
to: 'user@example.com',
subject: 'Welcome to our application!',
text: 'Thank you for signing up. Your account is now active.',
})
if (error) {
console.error('Failed to send email:', error)
return
}
console.log('Email sent:', data.id)HTML email
const { data, error } = await resend.emails.send({
from: 'notifications@yourdomain.com',
to: ['user1@example.com', 'user2@example.com'],
subject: 'New order #12345',
html: `
<h1>Order confirmation</h1>
<p>Thank you for placing your order!</p>
<p>Order number: <strong>#12345</strong></p>
<a href="https://example.com/orders/12345">View details</a>
`,
})Advanced options
const { data, error } = await resend.emails.send({
from: 'John Smith <john@yourdomain.com>',
to: 'user@example.com',
cc: ['manager@company.com'],
bcc: ['archive@company.com'],
reply_to: 'support@yourdomain.com',
subject: 'Important message',
html: '<p>Message content</p>',
// Attachments
attachments: [
{
filename: 'report.pdf',
content: pdfBuffer, // Buffer or base64 string
},
],
// Tags for tracking
tags: [
{ name: 'category', value: 'order_confirmation' },
{ name: 'user_id', value: '12345' },
],
// Scheduled sending
scheduled_at: '2024-12-25T09:00:00Z',
// Headers
headers: {
'X-Entity-Ref-ID': 'order-12345',
},
})React Email - Templates as components
Creating a template
React Email allows you to build email templates as React components:
// emails/welcome.tsx
import {
Html,
Head,
Body,
Container,
Section,
Text,
Button,
Img,
Link,
Preview,
Hr,
} from '@react-email/components'
interface WelcomeEmailProps {
username: string
verificationUrl: string
}
export default function WelcomeEmail({
username,
verificationUrl,
}: WelcomeEmailProps) {
return (
<Html>
<Head />
<Preview>Welcome to CodeWorlds, {username}!</Preview>
<Body style={main}>
<Container style={container}>
<Img
src="https://yourdomain.com/logo.png"
width={150}
height={50}
alt="CodeWorlds"
/>
<Section style={section}>
<Text style={heading}>Welcome, {username}!</Text>
<Text style={text}>
Thank you for joining CodeWorlds. Your programming adventure
is just beginning!
</Text>
<Button style={button} href={verificationUrl}>
Activate account
</Button>
<Text style={text}>
Or copy this link into your browser:
</Text>
<Link href={verificationUrl} style={link}>
{verificationUrl}
</Link>
</Section>
<Hr style={hr} />
<Section style={footer}>
<Text style={footerText}>
© 2024 CodeWorlds. All rights reserved.
</Text>
<Link href="https://yourdomain.com/unsubscribe" style={footerLink}>
Unsubscribe from newsletter
</Link>
</Section>
</Container>
</Body>
</Html>
)
}
// Inline styles (required for emails)
const main = {
backgroundColor: '#f6f9fc',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
}
const container = {
backgroundColor: '#ffffff',
margin: '0 auto',
padding: '20px 0 48px',
marginBottom: '64px',
}
const section = {
padding: '0 48px',
}
const heading = {
fontSize: '24px',
fontWeight: 'bold',
marginBottom: '16px',
}
const text = {
fontSize: '16px',
lineHeight: '26px',
color: '#333',
}
const button = {
backgroundColor: '#5046e5',
borderRadius: '6px',
color: '#fff',
fontSize: '16px',
fontWeight: 'bold',
textDecoration: 'none',
textAlign: 'center' as const,
display: 'block',
padding: '12px 24px',
margin: '24px 0',
}
const link = {
color: '#5046e5',
textDecoration: 'underline',
wordBreak: 'break-all' as const,
}
const hr = {
borderColor: '#e6ebf1',
margin: '32px 0',
}
const footer = {
padding: '0 48px',
}
const footerText = {
fontSize: '12px',
color: '#8898aa',
}
const footerLink = {
fontSize: '12px',
color: '#8898aa',
}Sending with React Email
import { resend } from '@/lib/resend'
import WelcomeEmail from '@/emails/welcome'
export async function sendWelcomeEmail(
email: string,
username: string,
verificationToken: string
) {
const verificationUrl = `https://yourdomain.com/verify?token=${verificationToken}`
const { data, error } = await resend.emails.send({
from: 'CodeWorlds <welcome@yourdomain.com>',
to: email,
subject: `Welcome to CodeWorlds, ${username}!`,
react: WelcomeEmail({ username, verificationUrl }),
})
if (error) {
throw new Error(`Failed to send welcome email: ${error.message}`)
}
return data
}Previewing templates locally
// package.json
{
"scripts": {
"email:dev": "email dev --dir emails --port 3001"
}
}npm run email:dev
# Open http://localhost:3001 to preview templatesEmail template library
Order confirmation email
// emails/order-confirmation.tsx
import {
Html,
Head,
Body,
Container,
Section,
Row,
Column,
Text,
Button,
Img,
Hr,
} from '@react-email/components'
interface OrderItem {
name: string
quantity: number
price: number
imageUrl: string
}
interface OrderConfirmationProps {
orderNumber: string
customerName: string
items: OrderItem[]
subtotal: number
shipping: number
total: number
shippingAddress: string
trackingUrl: string
}
export default function OrderConfirmation({
orderNumber,
customerName,
items,
subtotal,
shipping,
total,
shippingAddress,
trackingUrl,
}: OrderConfirmationProps) {
return (
<Html>
<Head />
<Body style={main}>
<Container style={container}>
<Section style={header}>
<Text style={heading}>Order confirmation</Text>
<Text style={orderNum}>Order #{orderNumber}</Text>
</Section>
<Section style={section}>
<Text style={greeting}>Hi {customerName}!</Text>
<Text style={text}>
Thank you for your order. Here is the summary:
</Text>
</Section>
<Section style={itemsSection}>
{items.map((item, index) => (
<Row key={index} style={itemRow}>
<Column style={imageColumn}>
<Img
src={item.imageUrl}
width={64}
height={64}
alt={item.name}
style={itemImage}
/>
</Column>
<Column style={detailsColumn}>
<Text style={itemName}>{item.name}</Text>
<Text style={itemQuantity}>Qty: {item.quantity}</Text>
</Column>
<Column style={priceColumn}>
<Text style={itemPrice}>${item.price.toFixed(2)}</Text>
</Column>
</Row>
))}
</Section>
<Hr style={hr} />
<Section style={summarySection}>
<Row>
<Column><Text style={summaryLabel}>Products:</Text></Column>
<Column><Text style={summaryValue}>${subtotal.toFixed(2)}</Text></Column>
</Row>
<Row>
<Column><Text style={summaryLabel}>Shipping:</Text></Column>
<Column><Text style={summaryValue}>${shipping.toFixed(2)}</Text></Column>
</Row>
<Row>
<Column><Text style={totalLabel}>Total:</Text></Column>
<Column><Text style={totalValue}>${total.toFixed(2)}</Text></Column>
</Row>
</Section>
<Section style={section}>
<Text style={addressLabel}>Shipping address:</Text>
<Text style={address}>{shippingAddress}</Text>
</Section>
<Section style={ctaSection}>
<Button style={button} href={trackingUrl}>
Track shipment
</Button>
</Section>
</Container>
</Body>
</Html>
)
}
const main = { backgroundColor: '#f6f9fc', fontFamily: 'Arial, sans-serif' }
const container = { backgroundColor: '#ffffff', margin: '0 auto', padding: '40px' }
const header = { textAlign: 'center' as const, marginBottom: '32px' }
const heading = { fontSize: '28px', fontWeight: 'bold', color: '#1a1a1a' }
const orderNum = { fontSize: '14px', color: '#666' }
const greeting = { fontSize: '18px', fontWeight: '600' }
const text = { fontSize: '16px', color: '#333', lineHeight: '24px' }
const section = { marginBottom: '24px' }
const itemsSection = { backgroundColor: '#f9fafb', padding: '16px', borderRadius: '8px' }
const itemRow = { marginBottom: '16px' }
const imageColumn = { width: '80px' }
const detailsColumn = { paddingLeft: '16px' }
const priceColumn = { textAlign: 'right' as const }
const itemImage = { borderRadius: '8px' }
const itemName = { fontSize: '14px', fontWeight: '600', margin: '0' }
const itemQuantity = { fontSize: '12px', color: '#666', margin: '4px 0 0' }
const itemPrice = { fontSize: '14px', fontWeight: '600' }
const hr = { borderColor: '#e6ebf1', margin: '24px 0' }
const summarySection = { marginBottom: '24px' }
const summaryLabel = { fontSize: '14px', color: '#666' }
const summaryValue = { fontSize: '14px', textAlign: 'right' as const }
const totalLabel = { fontSize: '16px', fontWeight: 'bold' }
const totalValue = { fontSize: '16px', fontWeight: 'bold', textAlign: 'right' as const }
const addressLabel = { fontSize: '14px', fontWeight: '600', marginBottom: '8px' }
const address = { fontSize: '14px', color: '#666', whiteSpace: 'pre-line' as const }
const ctaSection = { textAlign: 'center' as const }
const button = {
backgroundColor: '#000',
color: '#fff',
padding: '12px 32px',
borderRadius: '6px',
fontSize: '14px',
fontWeight: 'bold',
textDecoration: 'none',
}Password reset email
// emails/password-reset.tsx
import {
Html,
Head,
Body,
Container,
Section,
Text,
Button,
Link,
} from '@react-email/components'
interface PasswordResetProps {
resetUrl: string
expiresIn: string
ipAddress: string
userAgent: string
}
export default function PasswordReset({
resetUrl,
expiresIn,
ipAddress,
userAgent,
}: PasswordResetProps) {
return (
<Html>
<Head />
<Body style={main}>
<Container style={container}>
<Section style={section}>
<Text style={heading}>Password reset</Text>
<Text style={text}>
We received a request to reset the password for your account.
Click the button below to set a new password.
</Text>
<Button style={button} href={resetUrl}>
Reset password
</Button>
<Text style={text}>
The link is valid for {expiresIn}. If you did not request a password
reset, please ignore this message.
</Text>
<Section style={securitySection}>
<Text style={securityHeading}>Request details:</Text>
<Text style={securityText}>IP: {ipAddress}</Text>
<Text style={securityText}>Browser: {userAgent}</Text>
</Section>
<Text style={footerText}>
If you do not recognize this activity,{' '}
<Link href="https://yourdomain.com/security" style={link}>
secure your account
</Link>
.
</Text>
</Section>
</Container>
</Body>
</Html>
)
}
const main = { backgroundColor: '#f6f9fc', fontFamily: 'Arial, sans-serif' }
const container = { backgroundColor: '#ffffff', margin: '0 auto', padding: '40px' }
const section = { padding: '0' }
const heading = { fontSize: '24px', fontWeight: 'bold', marginBottom: '16px' }
const text = { fontSize: '16px', lineHeight: '26px', color: '#333' }
const button = {
backgroundColor: '#dc2626',
color: '#fff',
padding: '14px 32px',
borderRadius: '6px',
fontSize: '16px',
fontWeight: 'bold',
textDecoration: 'none',
display: 'block',
textAlign: 'center' as const,
margin: '24px 0',
}
const securitySection = {
backgroundColor: '#fef2f2',
padding: '16px',
borderRadius: '8px',
marginTop: '24px',
}
const securityHeading = { fontSize: '14px', fontWeight: '600', margin: '0 0 8px' }
const securityText = { fontSize: '12px', color: '#666', margin: '4px 0' }
const footerText = { fontSize: '14px', color: '#666', marginTop: '24px' }
const link = { color: '#dc2626' }Next.js integration
API Route (App Router)
// app/api/send-email/route.ts
import { NextResponse } from 'next/server'
import { resend } from '@/lib/resend'
import WelcomeEmail from '@/emails/welcome'
export async function POST(request: Request) {
try {
const { email, username, verificationToken } = await request.json()
if (!email || !username) {
return NextResponse.json(
{ error: 'Email and username are required' },
{ status: 400 }
)
}
const verificationUrl = `${process.env.NEXT_PUBLIC_APP_URL}/verify?token=${verificationToken}`
const { data, error } = await resend.emails.send({
from: 'CodeWorlds <noreply@yourdomain.com>',
to: email,
subject: `Welcome to CodeWorlds, ${username}!`,
react: WelcomeEmail({ username, verificationUrl }),
})
if (error) {
console.error('Resend error:', error)
return NextResponse.json(
{ error: 'Failed to send email' },
{ status: 500 }
)
}
return NextResponse.json({ id: data.id })
} catch (error) {
console.error('Server error:', error)
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
)
}
}Server Action
// app/actions/email.ts
'use server'
import { resend } from '@/lib/resend'
import ContactEmail from '@/emails/contact'
interface ContactFormData {
name: string
email: string
subject: string
message: string
}
export async function sendContactEmail(formData: ContactFormData) {
try {
const { data, error } = await resend.emails.send({
from: 'Contact Form <contact@yourdomain.com>',
to: 'team@yourdomain.com',
reply_to: formData.email,
subject: `[Contact] ${formData.subject}`,
react: ContactEmail({
name: formData.name,
email: formData.email,
message: formData.message,
}),
})
if (error) {
return { success: false, error: error.message }
}
return { success: true, id: data.id }
} catch (error) {
return { success: false, error: 'Failed to send email' }
}
}Contact form
// components/ContactForm.tsx
'use client'
import { useState } from 'react'
import { sendContactEmail } from '@/app/actions/email'
export function ContactForm() {
const [isSubmitting, setIsSubmitting] = useState(false)
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
setIsSubmitting(true)
setMessage(null)
const formData = new FormData(e.currentTarget)
const result = await sendContactEmail({
name: formData.get('name') as string,
email: formData.get('email') as string,
subject: formData.get('subject') as string,
message: formData.get('message') as string,
})
setIsSubmitting(false)
if (result.success) {
setMessage({ type: 'success', text: 'Message sent!' })
e.currentTarget.reset()
} else {
setMessage({ type: 'error', text: result.error || 'An error occurred' })
}
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="name" className="block text-sm font-medium">
Full name
</label>
<input
type="text"
id="name"
name="name"
required
className="mt-1 block w-full rounded-md border px-3 py-2"
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium">
Email
</label>
<input
type="email"
id="email"
name="email"
required
className="mt-1 block w-full rounded-md border px-3 py-2"
/>
</div>
<div>
<label htmlFor="subject" className="block text-sm font-medium">
Subject
</label>
<input
type="text"
id="subject"
name="subject"
required
className="mt-1 block w-full rounded-md border px-3 py-2"
/>
</div>
<div>
<label htmlFor="message" className="block text-sm font-medium">
Message
</label>
<textarea
id="message"
name="message"
rows={4}
required
className="mt-1 block w-full rounded-md border px-3 py-2"
/>
</div>
{message && (
<div className={`p-3 rounded ${
message.type === 'success' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
}`}>
{message.text}
</div>
)}
<button
type="submit"
disabled={isSubmitting}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
>
{isSubmitting ? 'Sending...' : 'Send message'}
</button>
</form>
)
}Webhooks - Email status tracking
Webhook configuration
Resend can send webhooks for various events:
email.sent- Email has been sentemail.delivered- Email has been deliveredemail.opened- Email has been openedemail.clicked- A link in the email has been clickedemail.bounced- Email has bouncedemail.complained- User marked the email as spam
Webhook endpoint
// app/api/webhooks/resend/route.ts
import { NextResponse } from 'next/server'
import { headers } from 'next/headers'
import crypto from 'crypto'
const RESEND_WEBHOOK_SECRET = process.env.RESEND_WEBHOOK_SECRET!
interface ResendWebhookPayload {
type: string
created_at: string
data: {
email_id: string
from: string
to: string[]
subject: string
created_at: string
tags?: { name: string; value: string }[]
}
}
function verifySignature(payload: string, signature: string): boolean {
const expectedSignature = crypto
.createHmac('sha256', RESEND_WEBHOOK_SECRET)
.update(payload)
.digest('hex')
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
)
}
export async function POST(request: Request) {
try {
const headersList = headers()
const signature = headersList.get('resend-signature')
if (!signature) {
return NextResponse.json(
{ error: 'Missing signature' },
{ status: 401 }
)
}
const body = await request.text()
if (!verifySignature(body, signature)) {
return NextResponse.json(
{ error: 'Invalid signature' },
{ status: 401 }
)
}
const payload: ResendWebhookPayload = JSON.parse(body)
// Processing different event types
switch (payload.type) {
case 'email.sent':
console.log('Email sent:', payload.data.email_id)
await handleEmailSent(payload.data)
break
case 'email.delivered':
console.log('Email delivered:', payload.data.email_id)
await handleEmailDelivered(payload.data)
break
case 'email.opened':
console.log('Email opened:', payload.data.email_id)
await handleEmailOpened(payload.data)
break
case 'email.clicked':
console.log('Email clicked:', payload.data.email_id)
await handleEmailClicked(payload.data)
break
case 'email.bounced':
console.log('Email bounced:', payload.data.email_id)
await handleEmailBounced(payload.data)
break
case 'email.complained':
console.log('Email marked as spam:', payload.data.email_id)
await handleEmailComplained(payload.data)
break
default:
console.log('Unknown event type:', payload.type)
}
return NextResponse.json({ received: true })
} catch (error) {
console.error('Webhook error:', error)
return NextResponse.json(
{ error: 'Webhook processing failed' },
{ status: 500 }
)
}
}
// Handlers
async function handleEmailSent(data: ResendWebhookPayload['data']) {
// Save status in the database
}
async function handleEmailDelivered(data: ResendWebhookPayload['data']) {
// Update status in the database
}
async function handleEmailOpened(data: ResendWebhookPayload['data']) {
// Record open event for analytics
}
async function handleEmailClicked(data: ResendWebhookPayload['data']) {
// Track link clicks
}
async function handleEmailBounced(data: ResendWebhookPayload['data']) {
// Mark email as inactive
// Remove from mailing list
}
async function handleEmailComplained(data: ResendWebhookPayload['data']) {
// Immediately remove from all lists
// Add to blacklist
}Domain management
Domain verification
import { resend } from '@/lib/resend'
// Add a domain
const { data: domain, error } = await resend.domains.create({
name: 'yourdomain.com',
region: 'eu-west-1', // or 'us-east-1'
})
// List domains
const { data: domains } = await resend.domains.list()
// Verify domain
const { data: verified } = await resend.domains.verify('domain_id')
// Domain details (DNS records)
const { data: domainDetails } = await resend.domains.get('domain_id')
console.log(domainDetails.records) // DNS records to addDNS records
After adding a domain, Resend will return DNS records to configure:
// Example response
{
records: [
{
type: 'MX',
name: 'send',
value: 'feedback-smtp.eu-west-1.amazonses.com',
priority: 10
},
{
type: 'TXT',
name: 'send',
value: 'v=spf1 include:amazonses.com ~all'
},
{
type: 'TXT',
name: 'resend._domainkey',
value: 'p=MIGfMA0GCSqGSIb3DQEBAQUAA...'
}
]
}Audiences and contacts
Managing recipient lists
import { resend } from '@/lib/resend'
// Create an audience (list)
const { data: audience } = await resend.audiences.create({
name: 'Newsletter Subscribers'
})
// Add a contact
const { data: contact } = await resend.contacts.create({
audience_id: audience.id,
email: 'user@example.com',
first_name: 'John',
last_name: 'Smith',
unsubscribed: false,
})
// Retrieve contacts
const { data: contacts } = await resend.contacts.list({
audience_id: audience.id,
})
// Update a contact
await resend.contacts.update({
audience_id: audience.id,
id: contact.id,
first_name: 'John',
})
// Remove a contact
await resend.contacts.remove({
audience_id: audience.id,
id: contact.id,
})Sending to an audience
const { data, error } = await resend.emails.send({
from: 'Newsletter <newsletter@yourdomain.com>',
to: audience.id, // Audience ID instead of a specific email
subject: 'New blog article',
react: NewsletterEmail({ title: 'Article', content: '...' }),
})Batch sending
Sending multiple emails at once
import { resend } from '@/lib/resend'
const emails = [
{
from: 'newsletter@yourdomain.com',
to: 'user1@example.com',
subject: 'Newsletter #1',
html: '<p>Content 1</p>',
},
{
from: 'newsletter@yourdomain.com',
to: 'user2@example.com',
subject: 'Newsletter #1',
html: '<p>Content 2</p>',
},
// ... more emails
]
const { data, error } = await resend.batch.send(emails)
// data contains an array with each email's ID
console.log(data) // [{ id: 'email_1' }, { id: 'email_2' }]Personalized batch sending
interface Subscriber {
email: string
name: string
preferences: string[]
}
async function sendPersonalizedNewsletter(subscribers: Subscriber[]) {
const emails = subscribers.map(subscriber => ({
from: 'newsletter@yourdomain.com',
to: subscriber.email,
subject: `Hey ${subscriber.name}! New newsletter`,
react: NewsletterEmail({
name: subscriber.name,
topics: subscriber.preferences,
}),
tags: [
{ name: 'campaign', value: 'weekly-newsletter' },
{ name: 'subscriber_id', value: subscriber.email },
],
}))
// Batch in groups of 100 emails (Resend limit)
const batches = []
for (let i = 0; i < emails.length; i += 100) {
batches.push(emails.slice(i, i + 100))
}
const results = []
for (const batch of batches) {
const { data, error } = await resend.batch.send(batch)
if (error) {
console.error('Batch error:', error)
}
results.push(...(data || []))
// Wait between batches
await new Promise(resolve => setTimeout(resolve, 1000))
}
return results
}Rate limiting and error handling
Implementing rate limiting
import { resend } from '@/lib/resend'
class EmailService {
private queue: Array<() => Promise<void>> = []
private processing = false
private rateLimit = 10 // emails per second
async send(params: Parameters<typeof resend.emails.send>[0]) {
return new Promise((resolve, reject) => {
this.queue.push(async () => {
try {
const result = await this.sendWithRetry(params)
resolve(result)
} catch (error) {
reject(error)
}
})
this.processQueue()
})
}
private async processQueue() {
if (this.processing) return
this.processing = true
while (this.queue.length > 0) {
const batch = this.queue.splice(0, this.rateLimit)
await Promise.all(batch.map(fn => fn()))
await new Promise(resolve => setTimeout(resolve, 1000))
}
this.processing = false
}
private async sendWithRetry(
params: Parameters<typeof resend.emails.send>[0],
retries = 3
) {
for (let i = 0; i < retries; i++) {
const { data, error } = await resend.emails.send(params)
if (!error) {
return data
}
// Retry on rate limit or server error
if (error.statusCode === 429 || error.statusCode >= 500) {
const delay = Math.pow(2, i) * 1000 // Exponential backoff
await new Promise(resolve => setTimeout(resolve, delay))
continue
}
// Do not retry on other errors
throw error
}
throw new Error('Max retries exceeded')
}
}
export const emailService = new EmailService()Best practices
1. Use tags for tracking
await resend.emails.send({
// ...
tags: [
{ name: 'type', value: 'transactional' },
{ name: 'campaign', value: 'welcome-series' },
{ name: 'user_id', value: userId },
],
})2. Always validate email addresses
import { z } from 'zod'
const emailSchema = z.string().email()
async function sendEmail(to: string, subject: string, content: string) {
const validatedEmail = emailSchema.parse(to)
await resend.emails.send({
from: 'noreply@yourdomain.com',
to: validatedEmail,
subject,
html: content,
})
}3. Handle unsubscribe
// In every marketing email
<Text style={footerText}>
Don't want to receive these messages?{' '}
<Link href={`https://yourdomain.com/unsubscribe?email=${encodeURIComponent(email)}`}>
Unsubscribe
</Link>
</Text>4. Test templates before sending
// Use onboarding@resend.dev for testing
const { data, error } = await resend.emails.send({
from: 'onboarding@resend.dev', // Resend test domain
to: 'test@example.com',
subject: 'Test email',
react: TestEmail(),
})Pricing and limits
| Plan | Emails/month | Price | Limits |
|---|---|---|---|
| Free | 3,000 | $0 | 100/day |
| Pro | 50,000 | $20/mo | + $0.28/1000 over |
| Scale | 100,000 | $90/mo | + $0.25/1000 over |
| Enterprise | Custom | Custom | Dedicated infrastructure |
Rate limits
- Free: 2 emails/second
- Pro: 10 emails/second
- Scale: 50 emails/second
- Batch API: max 100 emails per request
FAQ - Frequently asked questions
Is Resend better than SendGrid?
Resend offers a better developer experience thanks to React Email and its modern API. SendGrid has more marketing features, but Resend is simpler to integrate in Next.js applications.
How do I send emails with attachments?
Use the attachments field with a Buffer or base64:
const { data } = await resend.emails.send({
// ...
attachments: [
{
filename: 'report.pdf',
content: Buffer.from(pdfData),
},
],
})Can I use my own domain right away?
Not immediately, since the domain needs verifying through DNS records. A test domain provided by the service covers first experiments but is not suitable for production.
How do I track opens?
Enable tracking in the dashboard and handle the corresponding event on your endpoint. Treat the numbers as indicative, since some mail clients block the mechanism open tracking relies on.
Deliverability, what you really pay for
This is the most important section here, because it covers the one problem the API will not solve for you. The service provides good sending infrastructure, but your domain's reputation is yours, and you are the one who builds or ruins it.
DNS configuration is a precondition rather than an option. You need three things: an SPF record naming who may send on your domain's behalf, a DKIM signature proving the message was not altered in transit, and a DMARC policy stating what to do with a message failing those checks. Without the full set, large mail providers increasingly reject messages outright rather than merely flagging them.
The second matter is separating traffic. Transactional and marketing mail belong on separate subdomains, one for system notifications and another for the newsletter. The reason is practical: a marketing campaign collecting many spam complaints can damage the whole domain's reputation and drag down password resets, which have to arrive every time.
The third is recipient list hygiene. An address that hard bounces must be removed and never written to again. Sending to dead addresses is one of the strongest signals mail providers use to identify a careless sender or one buying lists.
The fourth, most often skipped, is content. A message built entirely from one large image, with a link address differing from its visible text, or lacking a plain text version looks suspicious to filters no matter how well you configured DNS.
Resend against the alternatives
| Feature | Resend | Postmark | Amazon SES | SendGrid |
|---|---|---|---|---|
| Templates as components | yes, React Email | no | no | no |
| Entry barrier | low | low | high | medium |
| Cost at high volume | medium | high | lowest | medium |
| Transactional traffic separation | subdomains | separate streams built in | manual | manual |
| Log retention | 30 days | 45 days | configuration dependent | plan dependent |
| Free plan | 3,000 messages | limited trial | usage dependent | limited |
The choice usually reduces to three scenarios. For a Next.js application sending thousands of messages a month, Resend wins on convenience and on templates written in the same language as the rest of the application. At hundreds of thousands of messages a month, Amazon SES is many times cheaper and the difference stops being negligible, though you pay for it in configuration and the absence of a convenient dashboard. Postmark is worth considering when stream separation and detailed delivery diagnostics matter most.
Queueing and what happens during an outage
Sending a message is a network call to an external service, so it can fail, and the question is what happens then. Answering "we will try again" is not enough, because you also need to know when and how many times.
A sensible arrangement has the application not send directly but record the intent to send and hand control back to the user. A separate process picks up that intent, makes the call, and records the outcome. A brief outage then does not turn into a lost order confirmation, and the user is not waiting on something they cannot influence anyway.
Retries follow one rule whose violation hurts twice over. Retry only when you are unsure whether the message went out, and protect yourself with an idempotency key so a double execution does not produce two identical messages in a customer's inbox. A duplicate password reset looks harmless; a duplicate invoice does not.
It is also worth separating errors worth retrying from those that are not. Exceeding a rate limit or a server side failure is transient. An address rejected as invalid, or a refusal to send from an unverified domain, will not improve on the tenth attempt and belongs in error handling rather than in a queue.
Common mistakes
The first is keeping the key on the browser side. A sending key carries full privileges, so a call from a client component exposes it to every visitor. Send only from the server, in Next.js from an API route or a server action.
The second is leaving a contact form unprotected. A sending endpoint with no rate limiting will be found and used to blast spam from your domain, which costs you your reputation within hours.
The third is sending synchronously while handling a user request. If the service responds slower than usual, the user waits on a signup confirmation instead of getting an immediate response. Sending belongs off the critical path, outside the request cycle.
The fourth is ignoring feedback events. Notice of a hard bounce or a spam complaint arrives at your endpoint and has to mark the address in your database. Without that you keep writing to addresses that are damaging your reputation.
The fifth is testing only against your own inbox. A message that looks perfect in one mail client can break in another, and a corporate spam filter behaves differently from a personal mailbox. Check across at least three different recipients before launch.
The API is documented on the Resend site, and the template library lives in the React Email project.