Skip to main content
ASoc
Comparison

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.

The ASoc Team9 min read

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

ResendAmazon SES
Auth on the wireAuthorization: Bearer <key>AWS Signature V4 over the request
Realistic clientfetchAn AWS SDK client, or hand-rolled SigV4
Dependency cost in your appNone@aws-sdk/client-ses and its transitive deps
Credentials to storeOne API keyAccess key id + secret (or a role) + region
New account stateSends immediately after domain verificationSandbox: verified recipients only, until you request production access
Bounce / complaint handlingManaged, with dashboard + webhooksYou wire SNS topics and consume them yourself
TemplatingReact Email is a first-party pathBring your own, or SES templates
Price shapePer-month plan by volumePer-thousand-emails, no floor
Who owns deliverability reputationShared, vendor-managedYours, 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

SymptomCauseFix
SES sends in dev, silently fails for customersIdentity is still in the sandboxRequest production access; until it is granted, only verified recipients receive mail
Email works locally, never arrives from productionFire-and-forget promise in a serverless handlerSchedule it with after() so the invocation stays alive
A failed send returns 500 to the payment providerThe mail call is awaited inside the webhook's critical pathMake the sender return Promise<void> and swallow its own failures
AccessDenied on ses:SendEmailIAM policy is scoped to a different identity or regionScope the policy to the verified identity ARN and the region you send from
Replies vanish into a no-reply inboxreply_to not set on notification mailSet it to the human who should receive the answer, as refundRequest.ts does
Bundle grows after adding emailThe provider SDK is imported into shared codeKeep 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.

Keep reading

Comparison7 min read

Sass vs. Tailwind: 133 Lines and One Arbitrary Selector

Sass's four features, checked one at a time against this codebase's real @theme block, group-hover usage, and the single [&_selector] Tailwind still reaches for.

Read more
Comparison8 min read

shadcn vs. Tailwind Is a Category Error (One Runs on the Other)

92 components, 13 runtime dependencies, zero UI libraries — what hand-rolling actually cost this codebase, and the seven components shadcn would have handed over.

Read more