Resend to AWS SES Migration
Migrating from Resend to AWS SES: A Practical Guide
Resend earned its reputation on a clean SDK, React Email, and a developer experience that makes 5,000-message-per-month senders feel like first-class citizens. Once volume climbs past the included Pro tier or AWS consolidation enters the picture, SES becomes the next stop. The full migration runbook — React Email portability, Audiences and Broadcasts replacements, the SES event pipeline you will own, and the deliverability discipline that keeps inbox placement steady through the cutover.
> **TL;DR.** If you are migrating only transactional, plan 1–2 weeks on the SES side and keep React Email as-is. If you also need to replace Audiences and Broadcasts, plan an additional 1–2 engineering weeks for the contact store + bounce-sync layer described below.
Resend launched in 2023 with a clear bet: developers writing React in 2026 want to write email in React, see template previews instantly, and ship from a typed SDK without thinking about MIME boundaries or SMTP timeouts. The bet paid off — Resend is the email vendor of choice for new SaaS teams, indie shipped projects, and AI-era startups that grew up on Vercel and Linear. AWS SES is where those teams move when the platform engineering function arrives, when finance starts asking about per-vendor spend, or when a parent product on AWS consolidates infrastructure under one cloud.
This guide is written for the engineer running that migration.
## Where Resend and SES Sit in the Market
Resend is a managed email API designed around modern developer experience. It ships with an opinionated React Email integration, a polished dashboard, generous tooling, and pricing that targets early-stage teams. Operationally it sits on top of multiple sending backends — AWS SES is publicly disclosed as one of them — and abstracts the sending pool, IP rotation, and feedback loops away from you.
AWS SES is a raw email-sending service. There is no template editor, no dashboard analytics beyond reputation metrics, no contact list manager, and no campaign scheduler. What you get is a high-throughput send API, configuration sets for routing and tagging, an event firehose, and IAM-controlled access. The product is a primitive; the value comes from what you build around it.
The migration is fundamentally about deciding to own more of the stack in exchange for control, observability, and lower per-message cost.
## The Pricing Curve
Resend's pricing is designed for two segments: free for early development, Pro for shipped products under 50,000 sends per month, then a step into marketing-tier pricing for senders running broadcasts.
| Volume | Resend | AWS SES | Monthly Difference |
| ---------------- | -------------------------- | ------------- | ------------------- |
| 3,000 emails | $0 (Free tier) | $0.30 | Resend +$0.30 |
| 50,000 emails | $20/month (Pro) | $5.00/month | $15.00 SES savings |
| 100,000 emails | ~$45/month (Pro + overage) | $10.00/month | $35.00 SES savings |
| 500,000 emails | ~$200/month (custom tiers) | $50.00/month | $150.00 SES savings |
| 1,000,000 emails | ~$350/month (custom tiers) | $100.00/month | $250.00 SES savings |
At low volume the cost gap is small enough that Resend's developer experience pays for itself. The crossover where SES becomes obvious typically lands somewhere between 200,000 and 500,000 messages per month — the volume where engineering time to build event pipelines and broadcast tooling is recovered within one to two quarters of saved spend.
Two non-pricing drivers usually accelerate the decision:
- **IP control** — Resend manages the sending pool for you. If a noisy neighbor on the shared pool damages reputation, your inbox placement suffers and there is no escalation path other than "wait for it to recover." SES dedicated IPs at $24.95 per IP per month give you a reputation signal that is yours to manage.
- **AWS-native consolidation** — IAM-based credentials replace API keys, KMS handles encryption of suppression data, VPC endpoints keep traffic off the public internet, and CloudWatch unifies email metrics with the rest of your platform telemetry.
## API Migration: Resend SDK → AWS SES SDK
The Resend SDK and the AWS SDK for SES are both well-typed and idiomatic. The shape of a send call is conceptually identical; only the surface changes.
| Resend | AWS SES Equivalent | Notes |
| ------------------------------------------- | -------------------------------------------------- | ----------------------------------------------------------------------------------------- |
| `resend.emails.send()` | `SendEmailCommand` via `@aws-sdk/client-ses` | Core send operation. Same `from`/`to`/`subject` shape. |
| `react: <Email />` shorthand | `render(<Email />)` from `@react-email/render` | Render in app code, pass HTML and text to SES. |
| `tags: [{ name, value }]` | `Tags: [{ Name, Value }]` | Direct mapping. Used for CloudWatch breakdowns and event filtering. |
| `headers: { 'X-Entity-Ref-ID': '...' }` | `Headers` array on `SendEmailCommand` | Custom headers pass through unchanged. |
| `attachments: [{ filename, content }]` | `SendRawEmailCommand` with MIME-encoded attachment | SES requires the raw MIME path for attachments. Use Nodemailer or `mimetext` to assemble. |
| API key in `Authorization: Bearer` header | IAM credentials via SDK or SES SMTP credentials | Prefer IAM roles in production (no key rotation). |
| Webhook signing via `Svix-Signature` header | SNS message signature verification | SNS subscriptions sign messages; verify with the AWS SDK helper. |
| `resend.emails.batch.send()` (up to 100) | `SendBulkEmailCommand` (up to 50 destinations) | Loop and batch for larger lists. |
A typical Resend-to-SES code change for a transactional send:
```typescript
// Before — Resend
import { Resend } from 'resend';
import VerifyEmail from './emails/verify';
const resend = new Resend(process.env.RESEND_API_KEY);
await resend.emails.send({
from: 'Acme <hello@acme.com>',
to: user.email,
subject: 'Verify your email',
react: <VerifyEmail name={user.name} url={verifyUrl} />,
tags: [{ name: 'category', value: 'verification' }],
});
// After — AWS SES
import { render } from '@react-email/render';
import { SESClient, SendEmailCommand } from '@aws-sdk/client-ses';
import VerifyEmail from './emails/verify';
const ses = new SESClient({ region: 'us-east-1' });
const html = await render(<VerifyEmail name={user.name} url={verifyUrl} />);
const text = await render(<VerifyEmail name={user.name} url={verifyUrl} />, {
plainText: true,
});
await ses.send(
new SendEmailCommand({
Source: 'Acme <hello@acme.com>',
Destination: { ToAddresses: [user.email] },
Message: {
Subject: { Data: 'Verify your email' },
Body: {
Html: { Data: html },
Text: { Data: text },
},
},
ConfigurationSetName: 'transactional',
Tags: [{ Name: 'category', Value: 'verification' }],
})
);
```
The SES version is more verbose, but every concept maps one-to-one. React Email components, props, and styling are unchanged.
## React Email Stays — That Is the Point
The single biggest concern engineers raise about leaving Resend is template portability. React Email has no Resend lock-in. The library is open source, owned by the Resend team but designed from day one to be provider-agnostic. Components, layouts, Tailwind classes, dark mode handling, and plain-text rendering all continue to work after the migration.
What you keep:
- Every component in `@react-email/components` — `Html`, `Body`, `Container`, `Section`, `Button`, `Heading`, `Text`, `Hr`, `Tailwind`
- Storybook or `react-email preview` local rendering for design iteration
- TypeScript prop types on email templates
- Plain-text generation via the `plainText: true` render option
What you lose:
- The `react:` send shortcut — you render the component in application code instead
- Resend dashboard preview history per send
A clean pattern is to put a thin `sendEmail()` helper in your shared infrastructure module that wraps the SES SDK and accepts a React Email component. Every send site in the application then looks like `sendEmail({ to, subject, component: <VerifyEmail ... /> })`, and you have a single place to add tagging, idempotency, and suppression logic.
## Replacing Resend Audiences and Contacts
Resend Audiences holds your contact list and tracks subscribe/unsubscribe state. SES has no equivalent — you build the contact store yourself. The model is small and the build is one to two engineering weeks.
A working contact store on AWS for a team in the 100K–1M sends-per-month range:
- **DynamoDB table** keyed on `audience_id#email` with attributes for `status` (subscribed, unsubscribed, bounced, complained), `subscribed_at`, `unsubscribed_at`, `metadata` (the `data` payload from Resend Audiences), and a `gsi_status` GSI for status-based queries.
- **List-Unsubscribe handler** — a Lambda fronted by API Gateway that flips the contact's `status` to `unsubscribed` when a recipient hits the one-click unsubscribe link required by Gmail and Yahoo for any sender above 5,000 messages per day.
- **Subscribe endpoint** — same Lambda pattern, double-opt-in flow recommended for marketing audiences to keep complaint rate low.
- **Bounce/complaint sync** — SES SNS events feed into the same table; hard bounces and complaints flip status automatically.
Migration data flow:
1. Export each Resend Audience via the Resend API (`GET /audiences/:id/contacts`)
2. Import into DynamoDB with status preserved (subscribed, unsubscribed, etc.)
3. Verify your unsubscribe URLs in existing emails resolve to the new endpoint or set up a temporary redirect
4. Cut over send code to read from the new contact store
Building this is one to two engineering weeks for a focused team — meaningfully more if you also need a marketing-team-friendly admin UI to manage contacts, in which case you should evaluate whether keeping marketing on Resend (or a marketing-specific platform like Customer.io or Loops) and only moving transactional to SES is the right architecture.
## Replacing Resend Broadcasts
Broadcasts are scheduled, audience-targeted email campaigns with engagement reporting. The SES equivalent is a small orchestration layer:
```
EventBridge Scheduler (cron-style trigger)
↓
Step Functions state machine
↓ Distributed Map over audience contacts (parallelism: 100)
↓
Lambda (per-contact send)
↓ render React Email → SendBulkEmailCommand → SES
SES → recipient
↓
SES Configuration Set → Kinesis Firehose → S3 (raw events)
↓
Athena queries → broadcast_id → engagement metrics
```
Step Functions Distributed Map is the right primitive — it parallelizes the per-contact send up to 10,000 concurrent executions and gives you per-batch retry semantics out of the box. Compared to a hand-rolled SQS-driven sender, you get a visual execution view, structured failure handling, and a single execution ARN per broadcast that ties together logs, metrics, and replay.
Per-broadcast engagement reporting (open rate, click rate, bounce rate, complaint rate) comes from tagging every send with a `broadcast_id` Configuration Set tag, then aggregating SNS or Firehose events filtered by that tag. The result is a Postmark-style or Resend-style activity view that you own end-to-end.
## Domain Verification, DKIM, and the Authentication Stack
Resend handles domain verification through a simple "add these DNS records" UI and signs all outbound mail with their managed DKIM keys. SES asks you to do the same work but exposes more knobs. In 2026 — Gmail, Yahoo, and Microsoft now treat unauthenticated mail from any sender above 5,000 messages per day as effectively undeliverable — getting authentication right before the first production send is non-negotiable.
**SPF.** Add `include:amazonses.com` to your sending domain's TXT record. If you keep Resend running during the cutover window, your record looks like `v=spf1 include:_spf.resend.com include:amazonses.com -all` until you fully cut over. Watch the 10-DNS-lookup limit; chained `include:` directives silently break SPF and are the most common authentication regression during a phased migration.
**DKIM.** Verify the domain in SES, enable Easy DKIM, and publish the three CNAME records SES generates. Easy DKIM rotates keys automatically. During parallel sending, Resend's DKIM selector and SES's three CNAMEs coexist without conflict — both signatures validate independently.
**DMARC.** Publish `_dmarc.yourdomain.com` with `v=DMARC1; p=none; rua=mailto:dmarc@yourdomain.com` from day one to start collecting aggregate reports. Move to `p=quarantine` after two to four weeks of clean reports, then to `p=reject` once every legitimate sender — transactional, marketing, calendar invites, vendor notifications — is aligned. Gmail and Yahoo's bulk-sender requirements made enforcement mandatory; `p=none` meets the minimum but only enforcement actually blocks spoofing.
**Custom MAIL FROM domain.** Set up a custom MAIL FROM subdomain (e.g., `mail.acme.com`) and publish the SES-provided MX and SPF records for it. This makes SPF alignment with your visible From address explicit, which removes a class of subtle DMARC failures that surface only after `p=reject` enforcement.
**BIMI.** Once enforcement is stable and you have a Verified Mark Certificate from Entrust or DigiCert, publish a BIMI record so your logo renders next to messages in Gmail, Apple Mail, and Yahoo. BIMI does not directly improve placement but the trust signal lifts open rates measurably for transactional and lifecycle email.
## Step-by-Step Migration Plan
A clean Resend-to-SES cutover for a single product domain takes one to three weeks of focused engineering work. The phases below assume you are migrating both transactional and marketing traffic; pure transactional cutover is the first three phases only.
**Phase 1 — Inventory and parallel infrastructure (Week 1)**
1. Audit every send site in the codebase. Group by send category — transactional (verification, password reset, MFA, receipts), product activity (notifications, mentions), marketing (broadcasts, newsletters).
2. Verify your sending domain in SES. Publish DKIM and SPF records. Leave Resend records in place.
3. Create one Configuration Set per send category. Wire each to an SNS topic (or directly to Kinesis Firehose, see below). Tag every Configuration Set with `category`.
4. Move out of the SES sandbox by submitting a production access request. Expect 24–48 hours.
5. Stand up the contact store (DynamoDB + Lambda) if you used Resend Audiences.
**Phase 2 — Send code refactor (Week 1–2)**
1. Wrap every send call site behind a `sendEmail()` helper that accepts a React Email component, recipient, subject, and category.
2. Add a feature flag (e.g., `EMAIL_PROVIDER=resend|ses|both`) so you can route by category. Start sending non-critical categories (internal alerts, low-value notifications) through SES first.
3. Render React Email components in application code. Verify the rendered HTML matches what Resend was producing. Pixel-perfect parity is the bar for transactional templates.
4. Implement the per-send checks: account-level suppression cache, per-category preference store, frequency cap, idempotency key.
**Phase 3 — Event pipeline (Week 2)**
1. Wire every Configuration Set to Kinesis Data Firehose, partition by `year/month/day/category` in S3.
2. Build the smallest viable replacement for the Resend dashboard: a Lambda or Node.js API that queries Athena for recipient timeline, per-category metrics, bounce and complaint detail.
3. Add CloudWatch alarms on bounce rate (>2%), complaint rate (>0.1%), and send-rate anomalies (10x deviation from 7-day rolling average).
4. Verify SNS subscription signature on every webhook handler. Resend used Svix; SES uses SNS message signing — different validation library, same security guarantee.
**Phase 4 — Marketing migration (Week 2–3, optional)**
1. Build the broadcast orchestration layer (EventBridge Scheduler + Step Functions Distributed Map).
2. Migrate Audience data from Resend export to DynamoDB.
3. Run a small broadcast (1,000 recipients) end-to-end to validate sending, tracking, and unsubscribe handling.
4. Scale up gradually over 7–10 days, monitoring per-domain placement.
**Phase 5 — Cutover and decommission (Week 3+)**
1. Flip the feature flag for transactional traffic. Keep Resend running for 24–48 hours as fast rollback.
2. Watch CloudWatch dashboards and Resend dashboards in parallel. Investigate any divergence immediately.
3. After 7 clean days, remove Resend SDK from dependencies, rotate the API key, and remove Resend's SPF include from DNS.
## Common Migration Challenges
**Attachment encoding.** Resend's SDK accepts attachments as Base64 strings or buffers; SES `SendEmail` does not support attachments at all. You must use `SendRawEmailCommand` with a MIME-encoded message body. Use `mimetext` or Nodemailer's compose function to assemble the raw message — do not roll your own MIME serialization.
**Webhook signature drift.** Resend signs webhooks with Svix. SES signs SNS messages with X.509 certificates served from `*.amazonaws.com`. The SDK helper `verifyMessageSignature` validates incoming SNS notifications; do not skip the verification step. Public SNS HTTP endpoints with weak validation are an active target for spoofed bounce-event attacks that poison your suppression list.
**Apple MPP open inflation.** Resend's dashboard reports raw open events. After migration to SES, your event pipeline shows opens too — but Apple Mail Privacy Protection prefetches every image in a recipient's inbox, inflating the open rate by 20–60% with non-human signal. Filter MPP opens (identifiable by the `User-Agent: Mail/MPP` and Apple-owned IP ranges) before feeding engagement data into segmentation logic.
**Suppression list import latency.** Resend exposes hard-bounced and complained addresses through their API but historical data is limited. Export everything you can on the day of cutover and bulk-load it into the SES account-level suppression list via `PutSuppressedDestination`. Anything not on the list will hard-bounce and rebuild reputation damage before SES auto-suppresses.
**Rate limits and concurrent send patterns.** Resend's send API tolerates bursts well. SES enforces a per-second send rate (`MaxSendRate`) tied to your account quota. Code that fired 1,000 sends in parallel against Resend will throttle on SES until the quota grows. Smooth bulk sends through SQS or `SendBulkEmail` rather than Promise.all over a contact list.
**Domain verification across regions.** Resend is region-agnostic from your perspective. SES verification is per-region — verifying `acme.com` in `us-east-1` does not verify it in `eu-west-1`. If you split sending across regions for latency or data residency, publish DKIM and DMARC records for each region separately.
## Deliverability Discipline After the Cutover
Resend operates the sending pool, the feedback loops, and the throttling for you. After migration, all three become your responsibility. Three operational habits separate teams that maintain Resend-grade inbox placement on SES from teams that watch their open rate decay over six months.
**Stream isolation.** Run separate Configuration Sets — and ideally separate dedicated IP pools — for transactional, product activity, and marketing traffic. A complaint spike on a marketing broadcast cannot reach password-reset deliverability if the IPs are isolated. Application code selects the correct Configuration Set per send category; tag every send with `category` for downstream filtering.
**Engagement-based send eligibility.** Maintain a `last_engaged_at` timestamp per recipient updated nightly from open and click events. Suppress recipients with no engagement in 90 days from marketing streams (180 days for transactional). Gmail and Microsoft weight recent positive engagement heavily — a smaller, hotter list lifts placement for the entire domain, typically from the mid-80s to the mid-90s within 30 days.
**Bot and prefetch filtering.** Apple MPP, Microsoft Defender link scanning, Gmail image proxies, and corporate security gateways all generate engagement events that have nothing to do with a human reading the message. Filter these before feeding signals into engagement-based suppression logic; without filtering, engagement-based suppression suppresses your real subscribers.
**Per-domain placement testing.** Send to a small panel of monitored seed inboxes (Gmail, Outlook, Yahoo, iCloud) on every major broadcast. Inbox vs. promotions vs. spam folder placement is your earliest warning system — much earlier than the bounce rate metric, which only spikes after reputation damage is already done.
## Production Event Pipeline: SES → Kinesis Firehose → S3 → Node.js API
Resend's hosted dashboard gives you a clean event view with searchable activity logs. After cutover you need to replicate that surface to keep observability parity. The architecture most production SES senders converge on is streaming the SES event firehose into S3 and exposing a thin query API.
```
SES Configuration Set
↓ (event destination)
Kinesis Data Firehose
↓ (60-second buffer or 5 MB)
S3 (Parquet, partitioned by year/month/day/category)
↓
Athena ← Node.js API ← Dashboard / suppression service / Slack alerts
```
Subscribe each Configuration Set to a Firehose delivery stream. Enable dynamic partitioning so events split by category, sending IP, or domain at write time. Lifecycle the bucket: Standard for 30 days (hot analytics window), Standard-IA at 30 days, Glacier Flexible Retrieval at 180 days. Raw events are the cheapest part of the stack and the most useful during deliverability investigations.
The Node.js API layer is small — a Fastify or Hono service, an Athena query helper, and four to six endpoints:
- `GET /messages?recipient=foo@bar.com` — Resend-style activity view
- `GET /broadcasts/:id/metrics` — open, click, bounce, complaint by broadcast
- `GET /deliverability?domain=gmail.com&days=7` — per-receiver placement signals
- `GET /bounces?subType=MailboxFull&days=1` — operational alerting feed
- `POST /webhooks/slack` — bounce/complaint fan-out
- `POST /webhooks/replay/:event_id` — re-process a single event for debugging
Filtering bot and prefetch traffic at this layer (or upstream of it) is what makes the engagement metrics actionable. Open and click events from corporate security scanners and image proxies should be tagged `automated` and excluded from engagement-based suppression decisions; otherwise, engagement-based suppression gradually suppresses real subscribers whose mail clients prefetch links. Tools that score SES events for bot vs. human activity in real time fit naturally between the Firehose stream and the API layer; teams that skip this filtering often discover the gap only after a quarter of degraded marketing placement.
## Related Comparisons
Explore other technical comparisons:
- [SendGrid to AWS SES](/compare/sendgrid-to-aws-ses/)
- [Mailgun to AWS SES](/compare/mailgun-to-aws-ses/)
- [Postmark to AWS SES](/compare/postmark-to-aws-ses/)
- [SparkPost to AWS SES](/compare/sparkpost-to-aws-ses/)
## Why Choose FactualMinds for Your Email Migration
FactualMinds is an **AWS Select Tier Consulting Partner** specializing in email infrastructure migration. We have executed SendGrid, Mailgun, Postmark, SparkPost, and Resend to AWS SES migrations and know exactly where teams get stuck.
- **Email migration experts** — we handle domain verification, DKIM, bounce architecture, IP warming
- **Assessment-first approach** — we map your current state before writing a line of infrastructure code
- **Zero-downtime cutover planning included** — no failed deliveries during migration
- **AWS Select Tier Partner** — [verified on AWS Partner Network](https://partners.amazonaws.com/partners/001aq000008su2EAAQ/Factual%20Minds)
---TL;DR. If you are migrating only transactional, plan 1–2 weeks on the SES side and keep React Email as-is. If you also need to replace Audiences and Broadcasts, plan an additional 1–2 engineering weeks for the contact store + bounce-sync layer described below.
Resend launched in 2023 with a clear bet: developers writing React in 2026 want to write email in React, see template previews instantly, and ship from a typed SDK without thinking about MIME boundaries or SMTP timeouts. The bet paid off — Resend is the email vendor of choice for new SaaS teams, indie shipped projects, and AI-era startups that grew up on Vercel and Linear. AWS SES is where those teams move when the platform engineering function arrives, when finance starts asking about per-vendor spend, or when a parent product on AWS consolidates infrastructure under one cloud.
This guide is written for the engineer running that migration.
Where Resend and SES Sit in the Market
Resend is a managed email API designed around modern developer experience. It ships with an opinionated React Email integration, a polished dashboard, generous tooling, and pricing that targets early-stage teams. Operationally it sits on top of multiple sending backends — AWS SES is publicly disclosed as one of them — and abstracts the sending pool, IP rotation, and feedback loops away from you.
AWS SES is a raw email-sending service. There is no template editor, no dashboard analytics beyond reputation metrics, no contact list manager, and no campaign scheduler. What you get is a high-throughput send API, configuration sets for routing and tagging, an event firehose, and IAM-controlled access. The product is a primitive; the value comes from what you build around it.
The migration is fundamentally about deciding to own more of the stack in exchange for control, observability, and lower per-message cost.
The Pricing Curve
Resend’s pricing is designed for two segments: free for early development, Pro for shipped products under 50,000 sends per month, then a step into marketing-tier pricing for senders running broadcasts.
| Volume | Resend | AWS SES | Monthly Difference |
|---|---|---|---|
| 3,000 emails | $0 (Free tier) | $0.30 | Resend +$0.30 |
| 50,000 emails | $20/month (Pro) | $5.00/month | $15.00 SES savings |
| 100,000 emails | ~$45/month (Pro + overage) | $10.00/month | $35.00 SES savings |
| 500,000 emails | ~$200/month (custom tiers) | $50.00/month | $150.00 SES savings |
| 1,000,000 emails | ~$350/month (custom tiers) | $100.00/month | $250.00 SES savings |
At low volume the cost gap is small enough that Resend’s developer experience pays for itself. The crossover where SES becomes obvious typically lands somewhere between 200,000 and 500,000 messages per month — the volume where engineering time to build event pipelines and broadcast tooling is recovered within one to two quarters of saved spend.
Two non-pricing drivers usually accelerate the decision:
- IP control — Resend manages the sending pool for you. If a noisy neighbor on the shared pool damages reputation, your inbox placement suffers and there is no escalation path other than “wait for it to recover.” SES dedicated IPs at $24.95 per IP per month give you a reputation signal that is yours to manage.
- AWS-native consolidation — IAM-based credentials replace API keys, KMS handles encryption of suppression data, VPC endpoints keep traffic off the public internet, and CloudWatch unifies email metrics with the rest of your platform telemetry.
API Migration: Resend SDK → AWS SES SDK
The Resend SDK and the AWS SDK for SES are both well-typed and idiomatic. The shape of a send call is conceptually identical; only the surface changes.
| Resend | AWS SES Equivalent | Notes |
|---|---|---|
resend.emails.send() | SendEmailCommand via @aws-sdk/client-ses | Core send operation. Same from/to/subject shape. |
react: <Email /> shorthand | render(<Email />) from @react-email/render | Render in app code, pass HTML and text to SES. |
tags: [{ name, value }] | Tags: [{ Name, Value }] | Direct mapping. Used for CloudWatch breakdowns and event filtering. |
headers: { 'X-Entity-Ref-ID': '...' } | Headers array on SendEmailCommand | Custom headers pass through unchanged. |
attachments: [{ filename, content }] | SendRawEmailCommand with MIME-encoded attachment | SES requires the raw MIME path for attachments. Use Nodemailer or mimetext to assemble. |
API key in Authorization: Bearer header | IAM credentials via SDK or SES SMTP credentials | Prefer IAM roles in production (no key rotation). |
Webhook signing via Svix-Signature header | SNS message signature verification | SNS subscriptions sign messages; verify with the AWS SDK helper. |
resend.emails.batch.send() (up to 100) | SendBulkEmailCommand (up to 50 destinations) | Loop and batch for larger lists. |
A typical Resend-to-SES code change for a transactional send:
// Before — Resend
import { Resend } from 'resend';
import VerifyEmail from './emails/verify';
const resend = new Resend(process.env.RESEND_API_KEY);
await resend.emails.send({
from: 'Acme <hello@acme.com>',
to: user.email,
subject: 'Verify your email',
react: <VerifyEmail name={user.name} url={verifyUrl} />,
tags: [{ name: 'category', value: 'verification' }],
});
// After — AWS SES
import { render } from '@react-email/render';
import { SESClient, SendEmailCommand } from '@aws-sdk/client-ses';
import VerifyEmail from './emails/verify';
const ses = new SESClient({ region: 'us-east-1' });
const html = await render(<VerifyEmail name={user.name} url={verifyUrl} />);
const text = await render(<VerifyEmail name={user.name} url={verifyUrl} />, {
plainText: true,
});
await ses.send(
new SendEmailCommand({
Source: 'Acme <hello@acme.com>',
Destination: { ToAddresses: [user.email] },
Message: {
Subject: { Data: 'Verify your email' },
Body: {
Html: { Data: html },
Text: { Data: text },
},
},
ConfigurationSetName: 'transactional',
Tags: [{ Name: 'category', Value: 'verification' }],
})
);The SES version is more verbose, but every concept maps one-to-one. React Email components, props, and styling are unchanged.
React Email Stays — That Is the Point
The single biggest concern engineers raise about leaving Resend is template portability. React Email has no Resend lock-in. The library is open source, owned by the Resend team but designed from day one to be provider-agnostic. Components, layouts, Tailwind classes, dark mode handling, and plain-text rendering all continue to work after the migration.
What you keep:
- Every component in
@react-email/components—Html,Body,Container,Section,Button,Heading,Text,Hr,Tailwind - Storybook or
react-email previewlocal rendering for design iteration - TypeScript prop types on email templates
- Plain-text generation via the
plainText: truerender option
What you lose:
- The
react:send shortcut — you render the component in application code instead - Resend dashboard preview history per send
A clean pattern is to put a thin sendEmail() helper in your shared infrastructure module that wraps the SES SDK and accepts a React Email component. Every send site in the application then looks like sendEmail({ to, subject, component: <VerifyEmail ... /> }), and you have a single place to add tagging, idempotency, and suppression logic.
Replacing Resend Audiences and Contacts
Resend Audiences holds your contact list and tracks subscribe/unsubscribe state. SES has no equivalent — you build the contact store yourself. The model is small and the build is one to two engineering weeks.
A working contact store on AWS for a team in the 100K–1M sends-per-month range:
- DynamoDB table keyed on
audience_id#emailwith attributes forstatus(subscribed, unsubscribed, bounced, complained),subscribed_at,unsubscribed_at,metadata(thedatapayload from Resend Audiences), and agsi_statusGSI for status-based queries. - List-Unsubscribe handler — a Lambda fronted by API Gateway that flips the contact’s
statustounsubscribedwhen a recipient hits the one-click unsubscribe link required by Gmail and Yahoo for any sender above 5,000 messages per day. - Subscribe endpoint — same Lambda pattern, double-opt-in flow recommended for marketing audiences to keep complaint rate low.
- Bounce/complaint sync — SES SNS events feed into the same table; hard bounces and complaints flip status automatically.
Migration data flow:
- Export each Resend Audience via the Resend API (
GET /audiences/:id/contacts) - Import into DynamoDB with status preserved (subscribed, unsubscribed, etc.)
- Verify your unsubscribe URLs in existing emails resolve to the new endpoint or set up a temporary redirect
- Cut over send code to read from the new contact store
Building this is one to two engineering weeks for a focused team — meaningfully more if you also need a marketing-team-friendly admin UI to manage contacts, in which case you should evaluate whether keeping marketing on Resend (or a marketing-specific platform like Customer.io or Loops) and only moving transactional to SES is the right architecture.
Replacing Resend Broadcasts
Broadcasts are scheduled, audience-targeted email campaigns with engagement reporting. The SES equivalent is a small orchestration layer:
EventBridge Scheduler (cron-style trigger)
↓
Step Functions state machine
↓ Distributed Map over audience contacts (parallelism: 100)
↓
Lambda (per-contact send)
↓ render React Email → SendBulkEmailCommand → SES
SES → recipient
↓
SES Configuration Set → Kinesis Firehose → S3 (raw events)
↓
Athena queries → broadcast_id → engagement metricsStep Functions Distributed Map is the right primitive — it parallelizes the per-contact send up to 10,000 concurrent executions and gives you per-batch retry semantics out of the box. Compared to a hand-rolled SQS-driven sender, you get a visual execution view, structured failure handling, and a single execution ARN per broadcast that ties together logs, metrics, and replay.
Per-broadcast engagement reporting (open rate, click rate, bounce rate, complaint rate) comes from tagging every send with a broadcast_id Configuration Set tag, then aggregating SNS or Firehose events filtered by that tag. The result is a Postmark-style or Resend-style activity view that you own end-to-end.
Domain Verification, DKIM, and the Authentication Stack
Resend handles domain verification through a simple “add these DNS records” UI and signs all outbound mail with their managed DKIM keys. SES asks you to do the same work but exposes more knobs. In 2026 — Gmail, Yahoo, and Microsoft now treat unauthenticated mail from any sender above 5,000 messages per day as effectively undeliverable — getting authentication right before the first production send is non-negotiable.
SPF. Add include:amazonses.com to your sending domain’s TXT record. If you keep Resend running during the cutover window, your record looks like v=spf1 include:_spf.resend.com include:amazonses.com -all until you fully cut over. Watch the 10-DNS-lookup limit; chained include: directives silently break SPF and are the most common authentication regression during a phased migration.
DKIM. Verify the domain in SES, enable Easy DKIM, and publish the three CNAME records SES generates. Easy DKIM rotates keys automatically. During parallel sending, Resend’s DKIM selector and SES’s three CNAMEs coexist without conflict — both signatures validate independently.
DMARC. Publish _dmarc.yourdomain.com with v=DMARC1; p=none; rua=mailto:dmarc@yourdomain.com from day one to start collecting aggregate reports. Move to p=quarantine after two to four weeks of clean reports, then to p=reject once every legitimate sender — transactional, marketing, calendar invites, vendor notifications — is aligned. Gmail and Yahoo’s bulk-sender requirements made enforcement mandatory; p=none meets the minimum but only enforcement actually blocks spoofing.
Custom MAIL FROM domain. Set up a custom MAIL FROM subdomain (e.g., mail.acme.com) and publish the SES-provided MX and SPF records for it. This makes SPF alignment with your visible From address explicit, which removes a class of subtle DMARC failures that surface only after p=reject enforcement.
BIMI. Once enforcement is stable and you have a Verified Mark Certificate from Entrust or DigiCert, publish a BIMI record so your logo renders next to messages in Gmail, Apple Mail, and Yahoo. BIMI does not directly improve placement but the trust signal lifts open rates measurably for transactional and lifecycle email.
Step-by-Step Migration Plan
A clean Resend-to-SES cutover for a single product domain takes one to three weeks of focused engineering work. The phases below assume you are migrating both transactional and marketing traffic; pure transactional cutover is the first three phases only.
Phase 1 — Inventory and parallel infrastructure (Week 1)
- Audit every send site in the codebase. Group by send category — transactional (verification, password reset, MFA, receipts), product activity (notifications, mentions), marketing (broadcasts, newsletters).
- Verify your sending domain in SES. Publish DKIM and SPF records. Leave Resend records in place.
- Create one Configuration Set per send category. Wire each to an SNS topic (or directly to Kinesis Firehose, see below). Tag every Configuration Set with
category. - Move out of the SES sandbox by submitting a production access request. Expect 24–48 hours.
- Stand up the contact store (DynamoDB + Lambda) if you used Resend Audiences.
Phase 2 — Send code refactor (Week 1–2)
- Wrap every send call site behind a
sendEmail()helper that accepts a React Email component, recipient, subject, and category. - Add a feature flag (e.g.,
EMAIL_PROVIDER=resend|ses|both) so you can route by category. Start sending non-critical categories (internal alerts, low-value notifications) through SES first. - Render React Email components in application code. Verify the rendered HTML matches what Resend was producing. Pixel-perfect parity is the bar for transactional templates.
- Implement the per-send checks: account-level suppression cache, per-category preference store, frequency cap, idempotency key.
Phase 3 — Event pipeline (Week 2)
- Wire every Configuration Set to Kinesis Data Firehose, partition by
year/month/day/categoryin S3. - Build the smallest viable replacement for the Resend dashboard: a Lambda or Node.js API that queries Athena for recipient timeline, per-category metrics, bounce and complaint detail.
- Add CloudWatch alarms on bounce rate (>2%), complaint rate (>0.1%), and send-rate anomalies (10x deviation from 7-day rolling average).
- Verify SNS subscription signature on every webhook handler. Resend used Svix; SES uses SNS message signing — different validation library, same security guarantee.
Phase 4 — Marketing migration (Week 2–3, optional)
- Build the broadcast orchestration layer (EventBridge Scheduler + Step Functions Distributed Map).
- Migrate Audience data from Resend export to DynamoDB.
- Run a small broadcast (1,000 recipients) end-to-end to validate sending, tracking, and unsubscribe handling.
- Scale up gradually over 7–10 days, monitoring per-domain placement.
Phase 5 — Cutover and decommission (Week 3+)
- Flip the feature flag for transactional traffic. Keep Resend running for 24–48 hours as fast rollback.
- Watch CloudWatch dashboards and Resend dashboards in parallel. Investigate any divergence immediately.
- After 7 clean days, remove Resend SDK from dependencies, rotate the API key, and remove Resend’s SPF include from DNS.
Common Migration Challenges
Attachment encoding. Resend’s SDK accepts attachments as Base64 strings or buffers; SES SendEmail does not support attachments at all. You must use SendRawEmailCommand with a MIME-encoded message body. Use mimetext or Nodemailer’s compose function to assemble the raw message — do not roll your own MIME serialization.
Webhook signature drift. Resend signs webhooks with Svix. SES signs SNS messages with X.509 certificates served from *.amazonaws.com. The SDK helper verifyMessageSignature validates incoming SNS notifications; do not skip the verification step. Public SNS HTTP endpoints with weak validation are an active target for spoofed bounce-event attacks that poison your suppression list.
Apple MPP open inflation. Resend’s dashboard reports raw open events. After migration to SES, your event pipeline shows opens too — but Apple Mail Privacy Protection prefetches every image in a recipient’s inbox, inflating the open rate by 20–60% with non-human signal. Filter MPP opens (identifiable by the User-Agent: Mail/MPP and Apple-owned IP ranges) before feeding engagement data into segmentation logic.
Suppression list import latency. Resend exposes hard-bounced and complained addresses through their API but historical data is limited. Export everything you can on the day of cutover and bulk-load it into the SES account-level suppression list via PutSuppressedDestination. Anything not on the list will hard-bounce and rebuild reputation damage before SES auto-suppresses.
Rate limits and concurrent send patterns. Resend’s send API tolerates bursts well. SES enforces a per-second send rate (MaxSendRate) tied to your account quota. Code that fired 1,000 sends in parallel against Resend will throttle on SES until the quota grows. Smooth bulk sends through SQS or SendBulkEmail rather than Promise.all over a contact list.
Domain verification across regions. Resend is region-agnostic from your perspective. SES verification is per-region — verifying acme.com in us-east-1 does not verify it in eu-west-1. If you split sending across regions for latency or data residency, publish DKIM and DMARC records for each region separately.
Deliverability Discipline After the Cutover
Resend operates the sending pool, the feedback loops, and the throttling for you. After migration, all three become your responsibility. Three operational habits separate teams that maintain Resend-grade inbox placement on SES from teams that watch their open rate decay over six months.
Stream isolation. Run separate Configuration Sets — and ideally separate dedicated IP pools — for transactional, product activity, and marketing traffic. A complaint spike on a marketing broadcast cannot reach password-reset deliverability if the IPs are isolated. Application code selects the correct Configuration Set per send category; tag every send with category for downstream filtering.
Engagement-based send eligibility. Maintain a last_engaged_at timestamp per recipient updated nightly from open and click events. Suppress recipients with no engagement in 90 days from marketing streams (180 days for transactional). Gmail and Microsoft weight recent positive engagement heavily — a smaller, hotter list lifts placement for the entire domain, typically from the mid-80s to the mid-90s within 30 days.
Bot and prefetch filtering. Apple MPP, Microsoft Defender link scanning, Gmail image proxies, and corporate security gateways all generate engagement events that have nothing to do with a human reading the message. Filter these before feeding signals into engagement-based suppression logic; without filtering, engagement-based suppression suppresses your real subscribers.
Per-domain placement testing. Send to a small panel of monitored seed inboxes (Gmail, Outlook, Yahoo, iCloud) on every major broadcast. Inbox vs. promotions vs. spam folder placement is your earliest warning system — much earlier than the bounce rate metric, which only spikes after reputation damage is already done.
Production Event Pipeline: SES → Kinesis Firehose → S3 → Node.js API
Resend’s hosted dashboard gives you a clean event view with searchable activity logs. After cutover you need to replicate that surface to keep observability parity. The architecture most production SES senders converge on is streaming the SES event firehose into S3 and exposing a thin query API.
SES Configuration Set
↓ (event destination)
Kinesis Data Firehose
↓ (60-second buffer or 5 MB)
S3 (Parquet, partitioned by year/month/day/category)
↓
Athena ← Node.js API ← Dashboard / suppression service / Slack alertsSubscribe each Configuration Set to a Firehose delivery stream. Enable dynamic partitioning so events split by category, sending IP, or domain at write time. Lifecycle the bucket: Standard for 30 days (hot analytics window), Standard-IA at 30 days, Glacier Flexible Retrieval at 180 days. Raw events are the cheapest part of the stack and the most useful during deliverability investigations.
The Node.js API layer is small — a Fastify or Hono service, an Athena query helper, and four to six endpoints:
GET /messages?recipient=foo@bar.com— Resend-style activity viewGET /broadcasts/:id/metrics— open, click, bounce, complaint by broadcastGET /deliverability?domain=gmail.com&days=7— per-receiver placement signalsGET /bounces?subType=MailboxFull&days=1— operational alerting feedPOST /webhooks/slack— bounce/complaint fan-outPOST /webhooks/replay/:event_id— re-process a single event for debugging
Filtering bot and prefetch traffic at this layer (or upstream of it) is what makes the engagement metrics actionable. Open and click events from corporate security scanners and image proxies should be tagged automated and excluded from engagement-based suppression decisions; otherwise, engagement-based suppression gradually suppresses real subscribers whose mail clients prefetch links. Tools that score SES events for bot vs. human activity in real time fit naturally between the Firehose stream and the API layer; teams that skip this filtering often discover the gap only after a quarter of degraded marketing placement.
Related Comparisons
Explore other technical comparisons:
Why Choose FactualMinds for Your Email Migration
FactualMinds is an AWS Select Tier Consulting Partner specializing in email infrastructure migration. We have executed SendGrid, Mailgun, Postmark, SparkPost, and Resend to AWS SES migrations and know exactly where teams get stuck.
- Email migration experts — we handle domain verification, DKIM, bounce architecture, IP warming
- Assessment-first approach — we map your current state before writing a line of infrastructure code
- Zero-downtime cutover planning included — no failed deliveries during migration
- AWS Select Tier Partner — verified on AWS Partner Network
Frequently Asked Questions
Why would I leave Resend for AWS SES?
Does Resend run on AWS SES under the hood?
How do I migrate React Email templates from Resend to AWS SES?
What is the SES equivalent of Resend Audiences and Broadcasts?
How does Resend pricing compare to AWS SES at scale?
Need Help Migrating to AWS SES?
FactualMinds is an AWS Select Tier Partner specializing in email infrastructure migration. We handle domain verification, Configuration Set architecture, bounce handling, IP warming, and cutover.
