We use cookies to enhance your experience on the site
CodeWorlds
Back to collections
Guide25 min read

Resend, an API for transactional email

Resend is an email API with templates written in React. Pricing, deliverability, domain setup, and a comparison with Postmark and SES.

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 planMonthly priceMessagesDomains
Free0 USD3,0001
Profrom 20 USD50,00010
Pro35 USD100,00010
Scalefrom 90 USD100,0001,000
Scale350 USD500,0001,000
Scale650 USD1,000,0001,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

Code
Bash
# 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-dev

API key configuration

Create an account at resend.com and generate an API key:

TSlib/resend.ts
TypeScript
// 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
Bash
# .env.local
RESEND_API_KEY=re_xxxxxxxxxxxxx

Basic email sending

Simple text email

Code
TypeScript
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

Code
TypeScript
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

Code
TypeScript
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:

TSemails/welcome.tsx
TypeScript
// 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

Code
TypeScript
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
JSON
// package.json
{
  "scripts": {
    "email:dev": "email dev --dir emails --port 3001"
  }
}
Code
Bash
npm run email:dev
# Open http://localhost:3001 to preview templates

Email template library

Order confirmation email

TSemails/order-confirmation.tsx
TypeScript
// 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

TSemails/password-reset.tsx
TypeScript
// 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)

TSapp/api/send-email/route.ts
TypeScript
// 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

TSapp/actions/email.ts
TypeScript
// 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

TScomponents/ContactForm.tsx
TypeScript
// 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 sent
  • email.delivered - Email has been delivered
  • email.opened - Email has been opened
  • email.clicked - A link in the email has been clicked
  • email.bounced - Email has bounced
  • email.complained - User marked the email as spam

Webhook endpoint

TSapp/api/webhooks/resend/route.ts
TypeScript
// 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

Code
TypeScript
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 add

DNS records

After adding a domain, Resend will return DNS records to configure:

Code
TypeScript
// 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

Code
TypeScript
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

Code
TypeScript
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

Code
TypeScript
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

Code
TypeScript
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

Code
TypeScript
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

Code
TypeScript
await resend.emails.send({
  // ...
  tags: [
    { name: 'type', value: 'transactional' },
    { name: 'campaign', value: 'welcome-series' },
    { name: 'user_id', value: userId },
  ],
})

2. Always validate email addresses

Code
TypeScript
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

Code
TypeScript
// 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

Code
TypeScript
// 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

PlanEmails/monthPriceLimits
Free3,000$0100/day
Pro50,000$20/mo+ $0.28/1000 over
Scale100,000$90/mo+ $0.25/1000 over
EnterpriseCustomCustomDedicated 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:

Code
TypeScript
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

FeatureResendPostmarkAmazon SESSendGrid
Templates as componentsyes, React Emailnonono
Entry barrierlowlowhighmedium
Cost at high volumemediumhighlowestmedium
Transactional traffic separationsubdomainsseparate streams built inmanualmanual
Log retention30 days45 daysconfiguration dependentplan dependent
Free plan3,000 messageslimited trialusage dependentlimited

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.