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.
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 form | Purchase welcome | |
|---|---|---|
| Trigger | A Server Action, user is waiting | A webhook, after money moved |
| The email is | The product itself | A courtesy receipt |
| On failure | Tell the user, with a fallback address | Log and move on |
| Can it throw to the caller? | It returns a failure state | Never — every path resolves void |
| Missing API key | Reported as an error to the user | Logged, 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
| Mistake | Symptom | Fix |
|---|---|---|
void sendEmail() after the response | Mail sends locally, drops in production | after(() => sendEmail()) |
| Awaiting mail inside the webhook's critical path | Webhook times out during a provider slowdown | Schedule it after the transaction commits |
| Throwing on mail failure in a webhook | Provider retries, buyer gets duplicate emails | Resolve void on every path |
| Returning the provider's error text to the user | Leaks vendor and key state | Log detail, return a generic message |
User's address in from | SPF/DKIM failures, spam folder | from = your verified domain, reply_to = user |
| Honeypot that reports failure | Bots adapt within days | Return the success state |
| Sending HTML because it looks professional | Rendering bugs, dark-mode breakage, build step | Plain 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.
