Transactional email in Next.js without duplicate sends
Build a safer Next.js email flow with one durable request, safe retries, provider receipts, and duplicate-resistant webhooks.

The short answer
Give one customer event one durable email record before any provider call.
- Treat the Route Handler as a public endpoint. Authenticate the caller, authorize the action, validate the input, and keep provider secrets on the server.
- Derive one stable request key from the business event, such as an order ID plus message version. Every retry must reuse it.
- Save the request with a database uniqueness rule, then let a durable outbox or queue dispatch it. The HTTP request should not be the only memory.
- Record provider receipts and deduplicate webhook events. If acceptance is unclear, reconcile the original request instead of creating a fresh send.
A successful provider call can look like a failed request
The shortest Next.js email endpoint parses JSON, calls a provider, and returns the response. It also has no answer for a lost response.
- 01The Route Handler asks the provider to send an order receipt.
- 02The provider accepts the email.
- 03The network closes before the Route Handler receives the answer.
- 04The function returns an error or times out.
- 05The browser, queue, or application tries again.
- 06The second request creates a second receipt email.
A blind retry may send the receipt twice. Avoiding all retries is not a solution because temporary failures still need recovery. The useful rule is: retries are allowed, but they must point to the same customer-visible action.
Engineers often call this behavior idempotency. In plain English, it is duplicate-send protection. One order receipt has one stable label. Repeating the request with that label returns the existing result instead of creating another email.
Use the Route Handler as a guarded front door
Next.js Route Handlers use the standard Web Request and Response APIs inside an App Router route.ts file. Next.js also states that Route Handlers are public HTTP endpoints. Their location in your repository does not make them private.
The handler should do a small, explicit set of jobs:
Authenticate
Identify the person, service, or signed application making the request.
Authorize
Check that caller can act for this account, order, template, sender, and recipient.
Validate
Limit body size and accept only the expected content type, fields, values, and template variables.
Name the action
Derive a stable request key from the business event. Do not invent a new random key inside every attempt.
Record
Create or read one durable email request under a database uniqueness rule.
Acknowledge
Return a public request ID and status. Do not expose secrets, message content, or internal errors.
Do not put the provider token in a Client Component. Next.js only exposes browser variables that use its public prefix, but a secret also needs server-only code, narrow permissions, separate environments, and safe logging.
Name the email after the event, not the HTTP attempt
The key should answer, "Which customer-visible message is this?" Good inputs come from trusted business records and a message version.
| Message | Stable request key | Why it stays stable |
|---|---|---|
| Order receipt | order-receipt:ord_842:v1 | The same order and receipt version keep the same identity across retries. |
| Password reset | password-reset:user_42:reset_9fd | A newly issued reset flow gets a new ID; a network retry does not. |
| Invoice issued | invoice-issued:inv_1042:v2 | A corrected invoice version can be a new message without confusing it with a retry. |
| Shipment update | shipment-dispatched:ship_771:v1 | The shipment event, not the browser click, identifies the notification. |
Store a hash of the normalized recipient, template version, and variables beside the key. If the same key returns with the same content, return the original record. If the content changed, return a conflict. Quietly changing the recipient or message under an old key makes the label meaningless.
A safer Next.js Route Handler shape
This example leaves database and authentication libraries open because the important contract is the order of operations. The database function must use a unique constraint, not a fragile read-then-write check.
// app/api/orders/[orderId]/receipt/route.ts
export async function POST(
request: Request,
{ params }: { params: Promise<{ orderId: string }> }
) {
const user = await requireUser(request)
const { orderId } = await params
const order = await requireViewableOrder(user, orderId)
// One stable label for one customer-visible message.
const requestKey = `order-receipt:${order.id}:v1`
const payload = buildReceiptEmail(order)
const result = await saveOrReadEmailRequest({
accountId: order.accountId,
requestKey,
recipient: order.customerEmail,
template: "order-receipt-v1",
payload,
})
if (result.kind === "conflict") {
return Response.json({ error: "request_key_conflict" }, { status: 409 })
}
if (result.kind === "created") {
await addToOutbox(result.emailRequest.id)
}
return Response.json(
{ id: result.emailRequest.id, status: result.emailRequest.status },
{ status: 202 }
)
}Put a unique database constraint on the account and request key. If two requests arrive together, the database decides which one created the record. The other reads the existing request. A process-level lock is insufficient when separate functions can run on separate machines.
Return 202 Accepted when the durable request exists and dispatch will continue asynchronously. Return a conflict when the same key arrives with different content. Ordinary authentication, validation, and rate-limit errors keep their ordinary status codes.
Let a durable outbox own provider dispatch
Next.js notes that some hosts deploy Route Handlers as lambda functions. Those handlers cannot share memory between requests, may not have a writable file system, and may be terminated by a timeout. An in-memory array or a promise left running after the response is not a delivery system.
A transactional outbox writes the business change and the email request in one database transaction. A separate worker claims pending records, talks to the provider, and stores the result. If it stops, another worker can resume from the same request.
| State | What is known | Safe next move |
|---|---|---|
| Pending | The request is durable; no worker owns it now. | Claim it with a short lease. |
| Dispatching | A worker is attempting the provider call. | Renew or release the lease; keep the same request key. |
| Accepted | The provider returned a message ID or equivalent receipt. | Wait for delivery events and reconcile status. |
| Unknown | The request left your system, but provider acceptance is unclear. | Query or safely retry the same request; never create a fresh intent. |
| Failed | A permanent validation, policy, or provider rejection is known. | Fix the cause before a separately authorized new request. |
Reuse the same provider key on every safe retry
Pass the stable request identity to the provider when its API supports duplicate protection. Provider behavior differs, so check the exact endpoint, time window, and conflict behavior you use. Resend, for example, currently documents duplicate protection for its single and batch email endpoints and keeps keys for 24 hours.
Provider protection is a useful second line of defense, not your only record. A provider window may expire, another provider may use a different contract, and your application still needs an explanation months later. The durable application record is the long-term truth about the intended message.
Unknown is safer than a convenient guess.
If the request body left your server and the response vanished, do not mark it failed merely because your code caught an error. Preserve the uncertain outcome, reconcile with the provider, and retry only under the same identity.
Moosewave's transactional email API uses the same plain contract: a repeated request with the same content returns the existing result, while a repeated key with different content conflicts. It also keeps the request, provider receipt, and later events connected.
Deduplicate webhook events before any side effect
Webhooks can repeat for the same reason API requests repeat: the sender may not receive your acknowledgement. Resend currently documents at-least-once webhook delivery and says event order is not guaranteed. Its guidance recommends using the event delivery ID to detect duplicates.
- 01Read the raw body when the provider's signature scheme requires it.
- 02Verify the signature and timestamp before trusting any field.
- 03Insert the provider event ID under a unique database constraint.
- 04Return success when that event is already stored.
- 05Update the email request only through allowed state transitions.
- 06Create any follow-up as a separately named business request, not an immediate side effect hidden inside the webhook.
A delivered event usually means the recipient's mail server accepted the message. It does not prove inbox placement, a human read, or a purchase. Keep provider acceptance, receiving-server acceptance, controlled placement observations, interactions, and product outcomes as separate facts. Moosewave's deliverability and analytics models preserve those distinctions.
The production checklist
- The Route Handler authenticates, authorizes, validates, and rate-limits the request.
- The provider secret stays in server-only code and the correct environment.
- The request key comes from a trusted business event and message version.
- A database uniqueness rule resolves concurrent duplicates.
- The same key with different recipient or content returns a conflict.
- A durable outbox or queue survives function termination.
- Retries use bounded backoff and the same request identity.
- Unclear provider outcomes remain unknown until reconciled.
- Webhook signatures are checked before events are stored.
- Webhook event IDs are deduplicated before side effects.
- Suppression and complaint state is checked before execution.
- Logs omit message content, reset tokens, and unnecessary personal data.
- A pause control stops unsent work without deleting evidence.
- Tests cover timeouts after provider acceptance and concurrent requests.
Moosewave is self-serve, and an AI agent is optional. A developer can use the API and inspect each request directly. A compatible agent can work through scoped MCP tools against the same permissions and receipts, which is useful for diagnosis and operations without creating a second sending path. Review the integration directory or open the product walkthrough to see the full lifecycle.
The production unit is one durable email request, any number of technical attempts, and one connected evidence history. Once the application remembers that unit, retries become recovery instead of a second permission to send.
Frequently asked questions
Can Next.js send transactional email?
Yes. A Next.js Route Handler or Server Action can record an email request on the server, and a worker can send it through an email provider. Keep provider credentials on the server, authorize the business action, and save the request before dispatching the message.
Why did my Next.js app send the same email twice?
The browser, a queue, or your hosting platform may repeat a request after a timeout or lost response. The first provider call may already have succeeded. If the retry has a new identity, the provider sees a new send and the recipient can get a duplicate.
What is an idempotency key in plain English?
It is a stable label for one intended action. Every retry of that action carries the same label, so the system returns the original result instead of doing the action again. For email, think one order receipt label, not one new label per HTTP attempt.
Should a Next.js Route Handler call the email provider directly?
It can be acceptable for low-consequence prototypes, but it leaves an awkward gap if the provider accepts the email and the function loses the response. For production receipts, resets, alerts, and other consequential messages, record the intent first and dispatch from a durable outbox or queue.
Should the browser create the duplicate-protection key?
Usually no. Derive it on the server from the authenticated business event, such as an order and receipt version. A caller-selected random value can change on every retry or be reused for an unrelated recipient.
Can transactional email webhooks arrive more than once?
Yes. Providers commonly retry webhook delivery when acknowledgements fail, and some document at-least-once delivery. Store the provider event ID with a unique database constraint and make repeated delivery return success without repeating downstream work.
Primary sources checked for this guide
- Next.js: Backend for Frontend. Current Route Handler guidance, including public endpoint security and deployment-environment limits.
- Next.js: Environment Variables. How Next.js loads server variables and which public prefix exposes values to the browser bundle.
- Resend: Idempotency Keys. The provider's current stable-key contract, supported endpoints, retention window, and conflict responses.
- Resend: Managing Webhooks. Current webhook retry schedule, at-least-once delivery, event IDs, and out-of-order delivery guidance.
Sources checked 28 August 2026. Product behavior and documentation can change, so the linked primary source takes precedence if it differs from this article.
Share this article
From field note to next move
Turn the question into a reviewable plan.
Give Moosewave the outcome you want. The goal carries into a guided workspace with its scope, approval points, and evidence still attached.
- 01UnderstandQuestion and evidence
- 02PlanScope and exclusions
- 03ApproveExact proposed action
- 04VerifyResult and receipt