Resend vs AWS SES: Three Send Sites, Zero Email Packages
This storefront sends mail from three files, all of them one fetch call. What SES would change, what it would improve, and what neither vendor fixes.
Both send transactional mail from a Next.js app, and both will deliver. The difference is what each one asks of your code before the first email leaves: Resend authenticates one POST with a bearer token, SES authenticates every request with a signed AWS request, which in practice means an SDK, an IAM identity, and a sandbox to escape first.
This storefront sends transactional mail from three places. All three are the same nine-line fetch, and the email provider contributes zero packages to package.json. That is the axis this comparison is actually decided on, so the post is built around the real code rather than around a pricing table that both vendors will have changed by the time you read it.
The short answer
Pick Resend if your send volume is small, your sends live inside request handlers, and you want the integration to be an HTTP call you can read in one screen. Pick Amazon SES if you already run on AWS, send at a volume where per-email cost dominates, or need the raw sending infrastructure to sit inside your own account and IAM policies.
The axes that actually diverge
| Resend | Amazon SES | |
|---|---|---|
| Auth on the wire | Authorization: Bearer <key> | AWS Signature V4 over the request |
| Realistic client | fetch | An AWS SDK client, or hand-rolled SigV4 |
| Dependency cost in your app | None | @aws-sdk/client-ses and its transitive deps |
| Credentials to store | One API key | Access key id + secret (or a role) + region |
| New account state | Sends immediately after domain verification | Sandbox: verified recipients only, until you request production access |
| Bounce / complaint handling | Managed, with dashboard + webhooks | You wire SNS topics and consume them yourself |
| Templating | React Email is a first-party path | Bring your own, or SES templates |
| Price shape | Per-month plan by volume | Per-thousand-emails, no floor |
| Who owns deliverability reputation | Shared, vendor-managed | Yours, per account and region |
Both are legitimate answers. Check each vendor's current pricing page before you plan around a number — this post deliberately quotes none, because the last three years say any figure here would be stale before the post is a quarter old.
What "one fetch" means, in the actual file
Here is the whole provider integration for the post-purchase email, from src/lib/email/purchaseWelcome.ts:
const res = await fetch("https://api.resend.com/emails", {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
from: "ASoc <noreply@asoctemplates.com>",
to: [email],
subject: "Your ASoc downloads are ready",
text,
}),
});
if (!res.ok) {
console.error("purchaseWelcome: resend error", res.status, await res.text());
}
Two other files send mail — src/lib/email/refundRequest.ts (support notification, with reply_to set to the buyer) and src/lib/actions/contact.ts (the contact form). Both are that same block with a different body. Nothing imports an email SDK, so the send path adds nothing to the server bundle and nothing to the dependency-audit surface.
The SES equivalent is not conceptually harder, but it is structurally different. You either add @aws-sdk/client-ses and construct a SendEmailCommand, or you implement SigV4 yourself — canonical request, string to sign, four-step derived signing key, per request. The first option is the sane one, and it means the email provider is now a dependency of the application rather than a URL the application posts to.
The credential shape is the part you feel later
.env.example in this repo lists one line for the email provider:
RESEND_API_KEY=re_xxxxxxxx
An SES integration replaces that with an access key id, a secret access key, and a region — or, better, an execution role with a ses:SendEmail policy scoped to a verified identity. The role is the right answer and it is also the reason SES rewards teams already on AWS: if your app runs on Lambda or ECS with an instance role, you ship no long-lived email credentials at all, which is strictly better than any API key. If your app runs on Vercel, as this one does, you are back to storing static IAM keys in environment variables, which is the same risk profile as the API key with more moving parts.
That asymmetry is most of the recommendation. SES's credential model is excellent where AWS already issues your identity and unremarkable where it does not.
The sandbox is the surprise, not the pricing
A new SES identity starts in the sandbox: you can only send to addresses or domains you have verified, with a low daily cap, until you request production access and it is granted. That is a sensible anti-abuse default and it is also the single most common reason a first SES integration "works" in development and sends nothing to a real customer.
Resend has no equivalent gate — you verify the sending domain, and sends to arbitrary recipients work. Whether that difference matters depends entirely on whether you are shipping this week or planning a year of volume.
The failure policy matters more than the vendor
Whichever one you choose, the more consequential decision is what your code does when the send fails. This storefront's purchase email runs after the entitlement has already been written to Postgres, so its contract is that it can never throw:
export async function sendPurchaseWelcome(
email: string,
tier: Tier,
): Promise<void> {
Promise<void>, and every path inside resolves — a missing key logs and returns, a network rejection is caught, a non-2xx response is logged. A mail failure cannot roll back a purchase or turn the payment webhook's 200 into a 500. Two emails, two failure policies works through the contact-form side, where the opposite rule applies and the user is told.
There is a second failure that belongs to serverless rather than to email. The webhook schedules its send through after() rather than calling it and moving on:
sendPurchaseWelcome: (email, tier) =>
after(() => sendPurchaseWelcome(email, tier)),
A promise you do not await has no guaranteed lifetime once the response is sent — the invocation can freeze before the HTTP call completes, and the email is silently lost. after() is backed by the platform's waitUntil(), so the work is kept alive without blocking the response. That applies identically to SES; it is a property of where the code runs, not of who delivers the mail.
The test suite does not know which vendor you use
src/lib/__tests__/purchaseWelcome.test.ts stubs fetch and asserts the contract rather than the provider:
it("fetch rejects (network error) → still resolves void, never throws", async () => {
vi.stubEnv("RESEND_API_KEY", "re_test_key");
vi.mocked(fetch).mockRejectedValueOnce(new Error("network down"));
await expect(
sendPurchaseWelcome("jane@example.com", "t2"),
).resolves.toBeUndefined();
});
That test survives a migration to SES with one edit to the stubbed environment variable — because the assertions are about never throwing and about which tier renders which label, not about the wire format. If your email code is only testable against a live provider, the vendor choice is the least of your problems. The full suite here is 31 files and 357 tests in 3.3 seconds, and none of it sends an email or touches a database.
Where SES is the straightforwardly better answer
- Your compute already runs in AWS and can assume a role, so email needs no stored secret.
- You send enough that per-thousand pricing is a line item someone asks about.
- You want bounce, complaint and delivery events landing in SNS/SQS beside the rest of your event plumbing.
- You are subject to a requirement that the sending infrastructure sit inside an account you control.
And where Resend is: you are one developer wiring three emails into a Next.js app, you want the integration to be a fetch call, and you would rather spend the afternoon on the product than on IAM.
Mistakes and how they show up
| Symptom | Cause | Fix |
|---|---|---|
| SES sends in dev, silently fails for customers | Identity is still in the sandbox | Request production access; until it is granted, only verified recipients receive mail |
| Email works locally, never arrives from production | Fire-and-forget promise in a serverless handler | Schedule it with after() so the invocation stays alive |
| A failed send returns 500 to the payment provider | The mail call is awaited inside the webhook's critical path | Make the sender return Promise<void> and swallow its own failures |
AccessDenied on ses:SendEmail | IAM policy is scoped to a different identity or region | Scope the policy to the verified identity ARN and the region you send from |
| Replies vanish into a no-reply inbox | reply_to not set on notification mail | Set it to the human who should receive the answer, as refundRequest.ts does |
| Bundle grows after adding email | The provider SDK is imported into shared code | Keep the send behind server-only and, for SES, import the client only in the handler that uses it |
Frequently asked questions
Is SES cheaper than Resend? Per email, at volume, generally yes — SES prices per thousand emails with no monthly floor. Whether that is the relevant number depends on your volume: at a few thousand emails a month the difference is smaller than the hours you spend on SigV4, IAM and the sandbox request. Read both current pricing pages; neither is stable enough to quote here.
Can I use React Email with SES? Yes. React Email renders to an HTML string; what sends it is unrelated. It is a first-party path on Resend and a perfectly ordinary dependency on SES.
Do I need an SDK for Resend?
No. The official SDK is a convenience over one HTTP endpoint, and this codebase does not use it — three call sites, all raw fetch, zero added packages.
Which one is better for deliverability? Neither, inherently. Deliverability follows domain authentication (SPF, DKIM, DMARC), list hygiene and complaint rates. SES gives you the raw pipe and holds you responsible for the reputation; Resend manages more of it for you. Both will get your mail into inboxes if your domain is set up correctly, and neither will if it is not.
Templates in this post
ASoc Surge is an AI-startup marketing site with tabbed capabilities and a neural-network explainer. ASoc Synth is an AI workspace SaaS landing page. ASoc Tempo is a time-tracking SaaS landing page with pricing and feature sections. Each ships the marketing half of a product whose signup and receipt emails would sit behind exactly the helper above.
Browse the full sets: Next.js landing page templates, Tailwind landing page templates.
