# How to send email in Next.js App Router—safely and reliably

> A production guide to email in Next.js: Route Handlers, Server Actions, secret isolation, typed templates, abuse controls, durable outboxes, idempotency, retries, delivery events, and testing.

- **Category:** Developer guide
- **Published:** July 30, 2026
- **Reading time:** 23 min read
- **Author:** Maya Chen, Email infrastructure
- **Canonical page:** [https://emailbump.com/blog/send-email-nextjs-app-router](https://emailbump.com/blog/send-email-nextjs-app-router)

Sending an email from Next.js can be one fetch call. Building a Next.js email system that does not expose credentials, become an open relay, duplicate receipts, lose welcome messages during a deployment, or confuse API acceptance with mailbox delivery takes more design.

This guide uses the App Router and TypeScript. The examples send through Email Bump’s transactional API, but the boundaries are provider-independent: untrusted requests enter through a Server Action or Route Handler, the application validates a named business intent, durable storage owns important work, and a server-only adapter talks to the email provider.

> **The browser should request an action—not compose an email**
>
> Let the client request “send my verification email” or “submit this contact form.” Resolve the recipient, sender, template, permissions, and delivery policy on the server.

![Production Next.js email architecture showing a browser request, protected server boundary, durable email outbox, asynchronous worker, and separate success milestones](https://emailbump.com/blog/nextjs-email-production-architecture.svg)

*Keep request handling, message intent, provider submission, and delivery evidence separate. Each boundary has different security and retry rules.*

## Choose the Next.js surface from the source of the event

Route Handlers and Server Actions both execute on the server, but they solve different entry problems. Neither is automatically trusted merely because its code lives in the server bundle.

### Next.js email entry points

```typescript
SURFACE              GOOD FIT                              SECURITY / RELIABILITY RULE
Route Handler        webhooks, public forms, API clients      authenticate or constrain every caller
Server Action        authenticated UI mutations              treat as a public mutation endpoint
Server function      email requested by trusted server code   keep provider module server-only
Durable worker       receipts, onboarding, security, billing  retry from persisted intent
Next.js after()      non-critical post-response work           not a durable queue by itself
Provider automation  timed lifecycle journeys                  synchronize consent and stop conditions
```

Use a Route Handler when the caller needs an HTTP contract: a webhook, a mobile client, a public contact form, or another service. Use a Server Action when an authenticated Next.js form performs a mutation. Use a plain server-only function when trusted application code already has the context it needs. For important email, all three should usually create durable intent rather than make user-visible success depend on a remote email request.

The Pages Router remains supported, and pages/api handlers are server-side bundles. New App Router applications normally use app/**/route.ts Route Handlers. Do not mix patterns simply to follow an older tutorial; choose one request boundary and give it the same validation, authorization, idempotency, and observability you would give any public API.

## Keep the email provider behind a server-only adapter

Create one small module that owns provider authentication and request shape. Mark it server-only so an accidental import from a Client Component fails during development or build. Keep the API key in a non-public environment variable supplied by the deployment secret store.

### lib/email/provider.ts

```typescript
import "server-only";

type EmailRequest = {
  messageId: string;
  from: string;
  to: string;
  replyTo?: string;
  subject: string;
  html: string;
  text: string;
};

type ProviderResult = { id: string };

function requireEmailApiKey() {
  const value = process.env.EMAIL_BUMP_API_KEY;
  if (!value) throw new Error("EMAIL_BUMP_API_KEY is not configured");
  return value;
}

export async function submitEmail(
  message: EmailRequest
): Promise<ProviderResult> {
  const response = await fetch("https://emailbump.com/api/v1/emails", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${requireEmailApiKey()}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      from: message.from,
      to: message.to,
      reply_to: message.replyTo,
      subject: message.subject,
      html: message.html,
      text: message.text,
    }),
    cache: "no-store",
    signal: AbortSignal.timeout(10_000),
  });

  if (!response.ok) {
    // Log status, request id, and messageId—not secrets or full content.
    throw new Error(`Email provider returned ${response.status}`);
  }

  return (await response.json()) as ProviderResult;
}
```

- Never name the credential NEXT_PUBLIC_EMAIL_BUMP_API_KEY. NEXT_PUBLIC values are bundled for the browser.
- Do not put secrets in the env property of next.config.js; values configured there are included in the JavaScript bundle.
- Keep .env.local out of version control and use separate credentials for development, preview, and production.
- Validate required server configuration during startup or the first server-only call, not after a customer completes a critical flow.
- Do not log Authorization headers, reset links, complete email bodies, or provider responses containing personal data.
- Rotate keys and make revocation possible without rebuilding client assets.

> **Prefer the Node.js runtime until compatibility is proven**
>
> A fetch-only adapter may work in an Edge runtime, but template renderers, SDKs, crypto, database clients, and observability libraries can depend on Node APIs. Set runtime = “nodejs” for the email boundary unless every dependency and deployment adapter is verified for Edge.

## Send a first message through a protected Route Handler

A minimal send endpoint should still model a named application operation. The caller does not choose an arbitrary From address, recipient, subject, or HTML body. The server authenticates the caller, loads the recipient from application state, and renders a known template.

### app/api/account/send-verification/route.ts

```typescript
import { randomUUID } from "node:crypto";
import { requireSession } from "@/lib/auth";
import { createVerificationToken } from "@/lib/tokens";
import { renderVerificationEmail } from "@/lib/email/templates";
import { submitEmail } from "@/lib/email/provider";

export const runtime = "nodejs";

export async function POST(request: Request) {
  const session = await requireSession(request);
  if (!session) {
    return Response.json({ error: "Unauthorized" }, { status: 401 });
  }

  if (session.user.emailVerified) {
    return Response.json({ status: "already_verified" });
  }

  const token = await createVerificationToken(session.user.id);
  const message = await renderVerificationEmail({
    name: session.user.name,
    token,
  });

  const result = await submitEmail({
    messageId: randomUUID(),
    from: "Email Bump <account@example.com>",
    to: session.user.email,
    subject: "Verify your email address",
    html: message.html,
    text: message.text,
  });

  return Response.json(
    { status: "accepted", messageId: result.id },
    { status: 202 }
  );
}
```

This direct pattern is appropriate for a low-volume prototype or an action whose failure is shown immediately and can safely be retried. It is not enough for a purchase receipt, security alert, or signup workflow that must survive a provider outage. Those need a durable outbox later in this guide.

Return 202 Accepted because the provider accepting a request does not prove the message reached the receiving server, inbox, or person. If the application instead commits an outbox job and has not contacted the provider yet, 202 is even more literal: work is owned and queued.

## Do not turn a public form into an email relay

Contact forms are the most common place a safe-looking tutorial becomes an abuse tool. If an unauthenticated caller can choose to, from, subject, or HTML, attackers can use your domain and account to send phishing or spam. Fix the destination and sender on the server. Put the visitor address in Reply-To only after validation.

### Safe contact-form contract

```text
CLIENT MAY SUPPLY                 SERVER MUST OWN
name                              recipient = support inbox
email                             From address and display name
message                           subject prefix and template
optional topic enum               HTML escaping and plain text
honeypot field                    rate limit and abuse decision
                                  message identity and logging policy

NEVER ACCEPT
{ to, from, html, templateId, apiKey } from a public browser request
```

Apply layered abuse controls: a small request-body limit, schema validation, per-IP and per-account rate limits, a bot signal or challenge when risk warrants it, a honeypot as low-cost evidence, duplicate-content detection, and an operational ceiling. CAPTCHA alone is not an authorization system. Keep the support recipient private if exposing it would invite direct abuse.

### Contact Route Handler outline

```typescript
export const runtime = "nodejs";

export async function POST(request: Request) {
  const length = Number(request.headers.get("content-length") ?? 0);
  if (length > 20_000) {
    return Response.json({ error: "Request too large" }, { status: 413 });
  }

  const input = await request.json().catch(() => null);
  const parsed = parseContactInput(input); // length, syntax, enum, honeypot
  if (!parsed.ok) {
    return Response.json({ error: "Invalid form" }, { status: 400 });
  }

  const limit = await contactRateLimit(request, parsed.value.email);
  if (!limit.allowed) {
    return Response.json(
      { error: "Try again later" },
      { status: 429, headers: { "Retry-After": String(limit.retryAfter) } }
    );
  }

  const job = await createContactEmailJob({
    replyTo: parsed.value.email,
    name: parsed.value.name,
    message: parsed.value.message,
  });

  return Response.json({ status: "queued", requestId: job.publicId }, { status: 202 });
}
```

Escape every untrusted value before placing it in HTML. Template literals do not escape markup. A signed-in customer can still submit a name such as an image tag with an event handler, and a webhook signature proves origin rather than string safety. Prefer typed template components whose text nodes are escaped by the renderer, and audit every raw-HTML escape hatch.

## Treat Server Actions as public mutation endpoints

A Server Action keeps implementation and credentials on the server, but a client can still invoke the action through the application protocol. Authenticate and authorize inside the action. Validate FormData as untrusted input. Do not assume that hiding a button, protecting a parent layout, or importing a function from a server file proves permission.

### app/actions/request-team-invite.ts

```typescript
"use server";

import "server-only";
import { requireSession } from "@/lib/auth";
import { db } from "@/lib/db";

export type InviteState = { ok: boolean; message: string };

export async function requestTeamInvite(
  _previous: InviteState,
  formData: FormData
): Promise<InviteState> {
  const session = await requireSession();
  if (!session) return { ok: false, message: "Sign in again." };

  const workspaceId = String(formData.get("workspaceId") ?? "");
  const email = normalizeEmail(String(formData.get("email") ?? ""));
  const membership = await db.membership.findUnique({
    where: { workspaceId_userId: { workspaceId, userId: session.user.id } },
  });

  if (membership?.role !== "admin" || !isValidEmail(email)) {
    return { ok: false, message: "The invitation could not be created." };
  }

  await db.$transaction(async (tx) => {
    const invite = await createOrReusePendingInvite(tx, { workspaceId, email });
    await createUniqueEmailJob(tx, {
      dedupeKey: `workspace-invite:${invite.id}:v2`,
      template: "workspace-invite",
      recipient: email,
      variables: { inviteId: invite.id, workspaceId },
    });
  });

  return { ok: true, message: "Invitation queued." };
}
```

The action creates application state and email intent in one transaction. Repeated clicks reuse a pending invitation and the unique dedupe key prevents another message intent. The browser receives a safe status rather than the provider’s error text, API request ID, or internal recipient record.

## Build typed templates with HTML and plain text

React Email is a useful authoring layer in a React codebase because templates accept typed props and render on the server. It does not remove the need for email-client testing, accessibility review, safe URLs, or a meaningful text alternative.

### emails/verification-email.tsx

```typescript
import {
  Body, Button, Container, Head, Heading, Html, Preview, Text,
} from "@react-email/components";

type Props = { name: string | null; verificationUrl: string };

export function VerificationEmail({ name, verificationUrl }: Props) {
  return (
    <Html lang="en">
      <Head />
      <Preview>Verify your email address to finish setting up your account</Preview>
      <Body style={{ background: "#f5f6f1", fontFamily: "Arial, sans-serif" }}>
        <Container style={{ maxWidth: 560, margin: "40px auto", background: "#fff", padding: 32 }}>
          <Heading style={{ fontSize: 26 }}>Verify your email</Heading>
          <Text style={{ fontSize: 16, lineHeight: "24px" }}>
            {name ? `Hi ${name}, ` : ""}confirm this address to finish setting up your account.
          </Text>
          <Button
            href={verificationUrl}
            style={{ background: "#17382d", color: "#fff", padding: "12px 18px" }}
          >
            Verify email
          </Button>
          <Text style={{ color: "#62736b", fontSize: 13 }}>
            If you did not create this account, you can ignore this message.
          </Text>
        </Container>
      </Body>
    </Html>
  );
}
```

### Render once for both MIME alternatives

```typescript
import { render, toPlainText } from "react-email";
import { VerificationEmail } from "@/emails/verification-email";

export async function renderVerificationEmail(props: {
  name: string | null;
  token: string;
}) {
  const url = new URL("/verify-email", requirePublicAppUrl());
  url.searchParams.set("token", props.token);

  const html = await render(
    <VerificationEmail name={props.name} verificationUrl={url.toString()} />
  );
  const text = toPlainText(html);

  return { html, text };
}
```

Generated plain text is a starting point, not proof of quality. Inspect whether headings, buttons, link destinations, image meaning, tables, codes, amounts, and support instructions survive conversion. Author the text version separately when the message is security-sensitive or the generated order is unclear.

- Set an accurate Preview value instead of letting navigation or image alt text become inbox preview text.
- Use absolute HTTPS URLs and a single trusted application-origin helper.
- Include width and height plus meaningful alt decisions for images.
- Use semantic headings and readable text sizes; test zoom and high contrast.
- Avoid CSS, JavaScript, forms, video, and layout assumptions that mailbox clients commonly remove.
- Version templates so retries and audits can identify the exact content policy used.
- Render representative missing, long, localized, and right-to-left values in tests.

## Design, preview, and inspect the message customers receive

Build reusable product-email content, preserve a useful text alternative, and keep message activity connected to the application event that created it.

- Reusable HTML and plain-text content
- Verified-domain sender identities
- Message activity and delivery evidence

[Learn more](https://emailbump.com/signup)

## Make action links secure even when the email is forwarded

Email links are bearer capabilities unless the destination requires another authenticated check. Password-reset, verification, invitation, and magic-login tokens should be random, purpose-bound, expiring, single-use where appropriate, and stored hashed when recovery of the raw value is unnecessary.

### Token policy

```text
TOKEN RECORD
hash              SHA-256 or keyed hash of the random token
purpose           verify_email | reset_password | workspace_invite
subject_id        the user, invite, or account the token may affect
expires_at        short, explicit lifetime
used_at           prevents replay where the operation is single-use
created_context   optional audit evidence, not an authorization substitute

ON CLICK
1. Parse one expected HTTPS origin and path.
2. Hash the presented token and find the exact purpose.
3. Check expiry, used state, subject state, and current policy.
4. Apply the mutation transactionally.
5. Mark the token used and invalidate conflicting tokens.
```

Do not put secrets, raw internal customer IDs, or personal data into query strings merely because the URL is HTTPS. URLs appear in browser history, analytics, proxy logs, support screenshots, and referrer flows. Keep token scope narrow and redirect to a clean URL after successful consumption.

## Use a durable outbox for email you cannot afford to lose

A direct provider call inside a signup request creates a two-system transaction. If the database commits and the provider call fails, the account exists without its email. If the provider accepts the message but the response disappears, a retry can send twice. A database outbox makes the application’s intent durable before a worker crosses the network boundary.

### PostgreSQL email outbox

```sql
CREATE TABLE email_outbox (
  id                  uuid PRIMARY KEY,
  dedupe_key          text NOT NULL UNIQUE,
  purpose             text NOT NULL,
  recipient_user_id   uuid,
  recipient_address   text NOT NULL,
  template_key        text NOT NULL,
  template_version    text NOT NULL,
  variables           jsonb NOT NULL,
  state               text NOT NULL DEFAULT 'pending',
  attempts            integer NOT NULL DEFAULT 0,
  next_attempt_at     timestamptz NOT NULL DEFAULT now(),
  provider_message_id text,
  last_error_class    text,
  created_at          timestamptz NOT NULL DEFAULT now(),
  accepted_at         timestamptz,
  delivered_at        timestamptz
);

CREATE INDEX email_outbox_ready
  ON email_outbox (next_attempt_at)
  WHERE state IN ('pending', 'retry_wait');
```

Insert the business record and outbox row in one database transaction. A worker claims ready rows, rechecks current eligibility, renders the stored template version, submits through the provider adapter, and records the outcome. Use exponential backoff with jitter for transient failures. Move exhausted, permanent, and ambiguous jobs into explicit states that operators can investigate.

### Welcome email

**Dedupe by user and welcome-template policy**

Create after the account transaction commits; suppress if the account is deleted before processing.

### Receipt

**Dedupe by payment or invoice and receipt version**

Snapshot immutable amount, currency, and line-item facts needed for an accurate retry.

### Password reset

**Dedupe and rate-limit by active reset request**

Decide whether a new request invalidates the previous token and whether a new message is useful.

### Contact form

**Dedupe obvious bot repeats without hiding legitimate retries**

The public response should not reveal internal recipient details or filtering decisions.

## Understand what after() does—and does not guarantee

Next.js after() schedules a callback after the response or prerender finishes. On supported serverless platforms it extends the invocation through a wait-until primitive, subject to the route’s configured duration. It is useful for non-blocking logs, analytics, cache work, or nudging a worker.

It is not durable storage. A deployment, process crash, platform timeout, adapter limitation, or exhausted invocation can still lose unfinished work. after() also runs when the response did not complete successfully, so the callback must not assume the user saw a success page.

### Safe use of after() with durable intent

```typescript
import { after } from "next/server";

export async function POST(request: Request) {
  const input = await validateAuthorizedOperation(request);

  // This commit is the reliability boundary.
  const job = await createUniqueEmailJob(input);

  // Optional latency optimization. Losing this callback is safe because
  // the scheduled worker still discovers the persisted pending row.
  after(async () => {
    await notifyWorkerThatEmailIsReady(job.id).catch(recordWakeupFailure);
  });

  return Response.json({ status: "queued", requestId: job.publicId }, { status: 202 });
}
```

> **Never fire and forget a bare promise**
>
> Calling void sendEmail() and immediately returning lets a serverless invocation freeze or terminate before the request completes. Await the operation, pass it to a supported lifecycle primitive, or persist work for a real worker.

## Design idempotency around the business message

A random request ID identifies one attempt, but it does not prevent two attempts from creating the same welcome or receipt. Build a unique semantic key from the business object, message purpose, and policy version.

### Useful idempotency keys

```text
user-welcome:{userId}:v3
email-verification:{verificationRequestId}:v2
workspace-invite:{inviteId}:v2
payment-receipt:{paymentId}:v4
invoice-failed:{invoiceId}:attempt:{attemptCount}:v2
contact-form:{normalizedFingerprint}:{tenMinuteBucket}

NOT ENOUGH
crypto.randomUUID() // unique attempt, no semantic deduplication
recipient address   // blocks legitimate future messages
subject line        // mutable and not a business identity
```

If the provider supports request idempotency, use the outbox ID as the provider key. Still retain the unique database key because provider retention windows and semantics can differ. If the connection fails after provider acceptance and no provider idempotency exists, mark the job ambiguous; reconcile activity before blindly resending a security or billing message.

## Separate retries from customer-visible form behavior

A user double-click, browser retry, React transition, proxy retry, or impatient refresh can repeat a mutation. Disable the button for usability, but enforce uniqueness on the server. A database constraint is stronger than an in-memory boolean and continues to work across instances.

- Return the existing result when an idempotency key already completed.
- Return a stable queued status while the same intent is still pending.
- Do not expose whether an arbitrary email address belongs to an account.
- Use neutral password-reset and invitation responses to reduce enumeration.
- Keep provider retry policy in the worker, not in a browser loop.
- Retry only transient classes; authentication, invalid sender, malformed payload, and policy failures need repair.

## Receive provider events as another untrusted boundary

The synchronous send response normally proves only that the provider accepted or queued the request. Delivery, deferral, bounce, complaint, and suppression outcomes arrive later. Receive them through a dedicated Route Handler, verify the provider signature against the required request representation, store event IDs durably, and process duplicates safely.

### Message state model

```text
pending → submitting → accepted
                    ↘ retry_wait → submitting
                    ↘ ambiguous → reconciled_accepted | retry_approved
                    ↘ failed_permanent

accepted → delivered
         → deferred → delivered | bounced
         → bounced
         → complained

Application state must not depend on delivered:
A paid account remains paid when its receipt bounces.
```

Carry the internal outbox ID through provider metadata when the API supports it, and always store the returned provider message ID. Webhook processing can then update one message ledger without searching by recipient or subject. Treat webhook payloads as sensitive operational data and apply explicit retention and access controls.

## Test the system at four levels

### Testing matrix

```text
TEMPLATE
Render representative props; inspect HTML and text; run accessibility and link checks.

APPLICATION
Mock the provider adapter; assert authorization, validation, outbox keys, and safe errors.

INTEGRATION
Use a non-production sending domain or sink; verify headers, MIME parts, and provider IDs.

END TO END
Complete signup, reset, invite, billing, and contact flows in a production-like deploy;
replay requests and delivery events; simulate provider timeout and database failure.
```

- Assert a Client Component cannot import the server-only provider module.
- Scan built client assets for test credential markers before deployment.
- Submit the same action concurrently and assert one email intent.
- Fail the database transaction and assert no success response or orphaned intent.
- Fail the provider before acceptance and assert the worker retries.
- Simulate an ambiguous timeout after acceptance and assert the job remains visible.
- Test rate limits and request-size limits without making real sends.
- Verify expired, reused, wrong-purpose, and revoked action tokens fail safely.
- Test long names, Unicode, missing optional values, zero-width characters, and HTML-like input.
- Inspect Gmail, Apple Mail, Outlook, mobile, dark mode, plain text, and images-off rendering.

## Instrument the complete path

A useful trace answers who requested the business action, whether it was authorized, which intent was committed, which template version rendered, whether the provider accepted it, and what delivery outcome followed. Use stable internal identifiers instead of putting full addresses and message bodies into logs.

### Minimum email telemetry

```text
request_trace_id · actor_user_id · workspace_id
application_event · business_object_id · idempotency_key
email_outbox_id · purpose · template_key · template_version
recipient_internal_id · sender_identity · stream
queue_age_ms · submit_attempt · error_class
provider_message_id · accepted_at · delivered_at
bounced_at · complaint_at · suppression_reason
runtime · deployment_version · worker_version
```

- Alert on oldest pending job age, not only total failures.
- Track provider acceptance latency separately from delivery-event latency.
- Measure duplicates prevented and ambiguous submissions requiring reconciliation.
- Alert on signature failures and delivery-event processing lag.
- Monitor bounces and complaints by message purpose, domain, and authenticated stream.
- Redact secrets, tokens, full content, and unnecessary personal data from logs and traces.

## Production checklist

- Model named application actions instead of accepting arbitrary email fields from the browser.
- Keep provider credentials in server-only environment variables and deployment secrets.
- Mark the provider module server-only and verify the chosen runtime supports every dependency.
- Authenticate and authorize inside every sensitive Route Handler and Server Action.
- Validate FormData, JSON, content type, body size, allowed origins where applicable, and rate limits.
- Fix public-form sender and recipient identities on the server; validate Reply-To.
- Escape untrusted HTML and avoid raw markup paths.
- Render and inspect both HTML and plain-text alternatives.
- Use secure, purpose-bound, expiring action tokens and clean post-consumption URLs.
- Create durable email intent in the same transaction as important business state.
- Use semantic database idempotency plus provider idempotency when available.
- Do not use a bare promise or after() as the sole reliability mechanism for critical email.
- Classify transient, permanent, and ambiguous provider failures separately.
- Verify and deduplicate provider delivery events.
- Test templates, application policy, provider integration, and full deployed flows.
- Monitor queue age, provider acceptance, delivery outcomes, bounces, and complaints.

## Send product email without losing the operational trail

Use the Email Bump transactional API for branded HTML and plain text, then connect every application intent to message activity, delivery, bounce, and complaint evidence.

[Start building](https://emailbump.com/signup)

## Sources

- [Next.js: Route Handler file convention](https://nextjs.org/docs/app/api-reference/file-conventions/route)
- [Next.js: Server and Client Components](https://nextjs.org/docs/app/getting-started/server-and-client-components)
- [Next.js: Environment variables](https://nextjs.org/docs/app/guides/environment-variables)
- [Next.js: Authentication and mutation security](https://nextjs.org/docs/app/guides/authentication)
- [Next.js: use server directive](https://nextjs.org/docs/app/api-reference/directives/use-server)
- [Next.js: after function](https://nextjs.org/docs/app/api-reference/functions/after)
- [Next.js: Testing guide](https://nextjs.org/docs/app/guides/testing)
- [React Email: Render HTML and plain text](https://react.email/docs/utilities/render)
- [Email Bump: Transactional API](/docs/transactional-api)
- [Email Bump: HTML to plain text converter](/tools/html-to-plain-text-converter)
- [Email Bump: Email accessibility checker](/tools/email-accessibility-checker)
- [Email Bump: Send emails from Stripe webhooks](/blog/send-emails-from-stripe-webhooks)
