Skip to content
Engineering

Send transactional email with Next.js: from request to delivery status

Run the request and status flow locally without an API key or any real delivery.

THE SHORT ANSWER

Call your email provider from server-side code, keep a stable identity for each logical send, and save the returned message ID. Treat the initial response as acceptance, then observe delivery separately. Start with a local fixture before introducing a real provider.

Start with the local API fixture

This walkthrough uses the Next.js App Router and standard fetch. The downloadable route is development-only and always calls a loopback fixture. It does not depend on a publicly available Stampwing endpoint. Stampwing remains in early access preparation.

Use Node.js 22 or later and an existing Next.js App Router project. Download the local email API fixture and start it in a separate terminal. It stores simulated messages in memory and never sends email.

Start the fixtureshell
node email-mock-server.mjs
# Listening on http://127.0.0.1:3027
# Simulated messages; no real delivery

Download email-mock-server.mjs →

Keep the provider call on the server

Download next-email-route.ts and place it at app/api/email-example/route.ts, or src/app/api/email-example/route.ts if your project uses a src directory. Start Next.js in development mode.

The route accepts only same-origin local requests, uses a fixed synthetic recipient, validates the operation key, and has a bounded upstream timeout. It intentionally ignores request-body recipient input, so the tutorial cannot become an arbitrary-recipient sending endpoint.

In a real app, authentication and business authorization belong before the send. Derive the recipient from the authorized business action, not from an untrusted form field. Keep provider secrets in server environment variables; never use a NEXT_PUBLIC_ variable for an API key.

The fixture call inside the routetypescript
const response = await fetch("http://127.0.0.1:3027/api/v1/emails", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "Idempotency-Key": key,
  },
  body: JSON.stringify({
    to: "reader@example.test",
    subject: "A local test",
    text: "No email leaves this fixture.",
  }),
  signal: AbortSignal.timeout(5000),
  cache: "no-store",
});

Make the request, then repeat it

The command below assumes Next.js is running at localhost:3000. If your port or hostname differs, change both the URL and Origin header to match exactly. Keep the idempotency key unchanged for the repeat request.

You should receive HTTP 202 with an ID, queued status, and demo mode. Running the same command again should return the same ID. A fixed fixture response is evidence of the protocol flow only; it is not a delivery event.

Call your development routeshell
curl -i http://localhost:3000/api/email-example \
  -X POST \
  -H "Origin: http://localhost:3000" \
  -H "Idempotency-Key: next-example-42"

Look up the accepted message separately

Copy the returned ID into the fixture lookup below. The response remains queued because the fixture does not simulate a receiving server. This makes the boundary visible: request acceptance and delivery observation are separate parts of the application.

For a real provider, store the ID with the original business event and update the delivery timeline through authenticated status lookups or verified webhooks. Limit polling and stop when the operation reaches an appropriate terminal or review state.

Replace RETURNED_ID with the fixture response IDshell
curl http://127.0.0.1:3027/api/v1/emails/RETURNED_ID

Understand the delivery status handoffs →

Make failure visible without losing the operation

Stop the fixture server and repeat the request. The Next.js example returns a service error rather than pretending the email was queued. Restarting the fixture clears its memory; production persistence must survive restarts.

An upstream timeout has an unknown outcome. Keep the same key for recovery. Validation failures, authorization failures, and conflicts require different handling from a temporary connection problem. Do not expose raw provider responses or secrets in a public error.

A production flow should use a durable outbox, bounded retry scheduling, rate limits, and an authenticated action. Those controls are deliberately not implied by a short route-handler tutorial.

Move to a provider with an explicit integration checklist

If you already have access to a Stampwing workspace, its connection flow supplies the deployment-specific endpoint and a scoped Test credential. This public tutorial makes no assumption about a hosted service URL or availability.

  1. Choose the provider endpoint and documented API contract. Configure its server-side credentials separately for Test and Live.
  2. Replace the fixture adapter while retaining the same application event identity and stored payload.
  3. Validate authorization, recipient derivation, rate limits, and template rendering in your application.
  4. Add signed webhooks or a scoped status lookup and test duplicates before enabling Live delivery.
  5. Complete domain authentication and a controlled delivery exercise. Keep production sending out of automated tests.

Sources and further reading

Written for Stampwing with AI assistance and checked against the linked documentation. Examples are educational; simulated results are labelled. How these resources are maintained.