Skip to main content
ASoc
Tutorial

Transactional Email in Next.js That Can't Break the Purchase It Confirms

Two emails, opposite failure policies — and the fire-and-forget bug that sends mail perfectly in dev and silently drops it in production.

The ASoc Team11 min read

A transactional email should never be able to break the transaction it describes. Sending mail from Next.js is four lines of fetch; the engineering is deciding, per email, whether a failure is reported to the user or swallowed into a log — and making sure the send survives a serverless invocation that freezes the moment you return a response.

This storefront sends two emails. They use the same API, the same key, and the same helper shape, and they have opposite failure policies. That contrast is the whole design, so this post is built around it. The form mechanics — useActionState, validation, progressive enhancement — belong to the contact form post; everything here is about what happens after the user stops looking.

Two emails, two failure policies

Contact formPurchase welcome
TriggerA Server Action, user is waitingA webhook, after money moved
The email isThe product itselfA courtesy receipt
On failureTell the user, with a fallback addressLog and move on
Can it throw to the caller?It returns a failure stateNever — every path resolves void
Missing API keyReported as an error to the userLogged, treated as success

The contact form's email is the feature. If it doesn't send, the user's message is gone, and telling them "sent" would be a lie:

if (!res.ok) {
  console.error("contact: resend error", res.status, await res.text());
  return {
    ok: false,
    message: "Something went wrong — email us at support@asoctemplates.com.",
  };
}

Note the shape of that message. The log gets the status and body; the user gets a generic sentence and a way to reach us anyway. Leaking a provider's error text into the UI tells an attacker which vendor you use and how your keys are failing.

The purchase welcome is the opposite. By the time it runs, the payment has settled and the entitlement transaction has committed. There is nothing left to fail:

/**
 * Must never throw to the caller: the webhook calls this fire-and-forget
 * *after* the entitlement transaction has already committed, so a mail
 * failure — or a missing `RESEND_API_KEY` — can never roll back the
 * purchase or turn the webhook's 200 into a 500.
 */
export async function sendPurchaseWelcome(
  email: string,
  tier: Tier,
): Promise<void> {
  const apiKey = process.env.RESEND_API_KEY;
  if (!apiKey) {
    console.error("purchaseWelcome: RESEND_API_KEY not configured");
    return;
  }
  // ...
  try {
    const res = await fetch("https://api.resend.com/emails", { /* ... */ });
    if (!res.ok) {
      console.error("purchaseWelcome: resend error", res.status, await res.text());
    }
  } catch (err) {
    console.error("purchaseWelcome: network error", err);
  }
}

Three exits, all void: no key, bad response, network error. The return type is Promise<void> rather than Promise<boolean> deliberately — there is no success value for a caller to accidentally branch on.

Why a throw here would be expensive

Payment providers retry webhooks that don't return 2xx. If a mail outage turned the webhook's 200 into a 500, the provider would redeliver the order, and every retry would attempt the grant again. The idempotency layer would absorb the duplicate grant, but the buyer would get one welcome email per retry once mail recovered. An email service having a bad afternoon should not become a mailbomb.

The ordering rule that falls out of this: schedule the email after the transaction commits, and never inside it.

The failure that only happens in production

Here is the bug that makes this post worth writing. This looks correct, passes review, and works perfectly on your machine:

// Don't do this.
void sendPurchaseWelcome(email, tier);
return new Response("ok", { status: 200 });

On a serverless platform the invocation can freeze or terminate as soon as the response is sent. A promise you started but never awaited has no guaranteed lifetime. Locally, the Node process keeps running and the mail goes out every time; in production it goes out sometimes, depending on whether the runtime happened to still be warm. It is the worst class of bug — invisible in dev, intermittent in prod, and it looks like a deliverability problem rather than a code problem.

Awaiting it is not the fix either, because then a slow mail API delays the webhook's response and pushes you toward the provider's timeout.

The fix is to schedule the work explicitly:

import { after } from "next/server";

sendPurchaseWelcome: (email, tier) => after(() => sendPurchaseWelcome(email, tier)),

after() is backed by the platform's waitUntil(), which keeps the invocation alive for scheduled work without blocking the response. We use it in both places mail is sent from a non-blocking path — the webhook above and the refund-request notification inside a Server Action:

sendNotification({ orderId: id }) {
  after(() => sendRefundRequestNotification(id));
},

The same trap applies to analytics beacons, audit writes and cache warming. If it happens after the response and you care whether it happens, it goes in after().

The send itself

There is no SDK in this codebase. A fetch to the provider's REST endpoint is smaller than the client library, has no version to keep current, and works identically in every runtime:

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,
  }),
});

Two choices in that payload are deliberate.

We send text, not html. A transactional receipt is a few sentences and a link. Plain text renders identically in every client, cannot break in dark mode, has nothing to strip, and skips an entire template build step. Reach for HTML when the email genuinely needs layout, not by default.

The contact email sets reply_to. The from is a noreply address we control — required, because you can only send from a domain you've verified — but support hitting reply needs to reach the person who wrote in:

from: "ASoc Contact <noreply@asoctemplates.com>",
to: ["support@asoctemplates.com"],
reply_to: email,

Putting the user's address in from instead is the common shortcut, and it fails your own SPF and DKIM checks, because your server is not authorized to send as their domain. That is a deliverability bug, not a style preference.

Guarding the inbound side

Both emails are triggered by user input, which makes them a spam relay if you don't bound them. The contact action does four things before it ever touches the API key:

// Honeypot: real users never fill this hidden field.
if (formData.get("company")) return { ok: true, message: "Message sent." };

const email = String(formData.get("email") ?? "").trim();
const message = String(formData.get("message") ?? "").trim().slice(0, 5000);
if (!email || !message) return { ok: false, message: "Please fill in your email and message." };
if (!EMAIL_RE.test(email)) return { ok: false, message: "Please enter a valid email address." };

The honeypot returns success. A bot that gets told "blocked" learns to stop filling the field; a bot that gets told "sent" has no signal to adapt to. The .slice(0, 5000) caps the payload before it reaches the provider, and the email regex runs before the key is read so malformed input costs nothing.

Mistakes table

MistakeSymptomFix
void sendEmail() after the responseMail sends locally, drops in productionafter(() => sendEmail())
Awaiting mail inside the webhook's critical pathWebhook times out during a provider slowdownSchedule it after the transaction commits
Throwing on mail failure in a webhookProvider retries, buyer gets duplicate emailsResolve void on every path
Returning the provider's error text to the userLeaks vendor and key stateLog detail, return a generic message
User's address in fromSPF/DKIM failures, spam folderfrom = your verified domain, reply_to = user
Honeypot that reports failureBots adapt within daysReturn the success state
Sending HTML because it looks professionalRendering bugs, dark-mode breakage, build stepPlain text for receipts

FAQ

Where should the API key live? A server-only environment variable, read inside the function rather than at module scope, and the module marked import "server-only" so a stray client import fails the build instead of shipping the key. A missing key should degrade the way the surrounding feature degrades — reported for the contact form, logged for the receipt.

How do I test an email path without sending mail? Inject the send. Our purchase-welcome helper is covered by a unit test that asserts it resolves void on a missing key, a non-2xx response and a network throw — the three failure exits — without ever reaching the network. Testing that it doesn't throw is more valuable than testing that it sends.

Do I need a queue? Not at this volume. after() covers one email per event with no infrastructure. Add a queue when you need retries with backoff, scheduled sends, or fan-out to thousands of recipients — that is a different product, and it starts at broadcast email rather than transactional.

How do I know an email actually arrived? You don't, from your own logs — a 200 from the API means accepted for delivery, not delivered. Providers expose delivery and bounce webhooks for that. Treat your own log line as "we handed it over" and nothing more. The same distinction decides what a confirmation screen may claim: a Next.js order confirmation page is a UX convenience, and the webhook is the source of truth.

Where this shows up in a template

Payment and fintech marketing sites carry the heaviest transactional-email load of anything in this catalog — receipts, statements, verification codes, alerts. The templates below ship the front end of that: contact and demo-request forms wired as Server Actions, ready for the delivery layer above to sit behind.

Keep reading

Tutorial7 min read

Next.js Turbopack: 13.9s vs. webpack's 18.3s, Same Commit

A measured head-to-head on 538 pages, plus the config trap: Turbopack serializes options to Rust, so an imported MDX plugin now fails the build outright.

Read more
Tutorial8 min read

The Latest Next.js Version Is 16.3.4. This Repo Runs 16.2.9.

npm carries sixteen dist-tags for `next` and `latest` is only one of them. Which version to be on, measured from a repo that pins the framework and ships it to other people.

Read more
Tutorial12 min read

Automated Product Screenshots with Playwright, 111 Templates Deep

The capture is four lines; deciding what is in frame is the work. Nav-driven page discovery, the is-this-the-product check, and the proxy bug that blanks every Chromium request.

Read more