# Resend × Next.js App Router: building production-quality email sending with Route Handlers and Server Actions

> A production-grade guide to calling Resend from the Next.js App Router: why you must never call it from the browser, the seven things the official minimal sample is missing, and Zod validation, rate limiting, spam absorption, HTML escaping, lazy init, idempotencyKey, and structured logging — all from code running in production.

- Published: 2026-08-06
- Author: 友田 陽大
- Tags: Resend, Next.js, TypeScript, メール配信, React Email, セキュリティ, 信頼性
- URL: https://tomodahinata.com/en/blog/resend-nextjs-app-router-route-handler-react-email-guide
- Category: Resend & transactional email
- Pillar guide: https://tomodahinata.com/en/blog/resend-transactional-email-production-guide

## Key points

- Calling the Resend API from the browser is not a design that can work. api.resend.com returns no Access-Control-Allow-Origin, so CORS always stops it, and the docs explicitly say to send server-side. The CORS error is not something to fix — it is the guardrail that stops your API key from going public.
- The official Next.js minimal sample works but is not production-ready. It is missing input validation, rate limiting, spam handling, HTML escaping, an idempotency key, protection against leaking errors to the client, and lazy client init. This article fills those seven gaps using the live app/api/contact/route.ts as the teaching material.
- idempotencyKey is the SECOND argument to emails.send. Only the 'AI prompt' block at the top of the official quickstarts puts it inside the payload — which does not type-check and, even if it did, is dropped before the request is built. This is the classic way AI-written code silently starts double-sending.
- Never call new Resend(...) at module scope. Without a key the constructor throws at import time, which in Next.js breaks the build itself. With lazy init the same situation surfaces as an observable 500.
- 'The SDK never throws' is only half true. The HTTP and network layers always come back as { data, error }, but the constructor and the react: rendering path do throw. That is why the official sample still has a try/catch.

---

The gap between "a contact form that works" and "a contact form you can run in production" is not about what happens when it succeeds. It is about **what happens when it fails**. When the provider returns a 5xx, when a bot hammers it once a second, when you forget one environment variable, when a retry fires — whether that turns into "the lead is gone", "the email went twice", "the build collapsed", or "nothing useful is in the logs" is what production quality actually means.

I run this portfolio site itself on Resend in production: the contact form, the lead-magnet delivery, a seven-part email course, and the post-payment delivery email after Stripe checkout — all of them call Resend from a Next.js Route Handler. I also once shipped a real outage: I put the domain-authentication DNS records on the **apex** instead of the sending subdomain, and `/api/contact` returned 502 on every submission until I found it. The design in the second half of this article came out of that recovery.

This article stays faithful to the [official Resend documentation](https://resend.com/docs/send-with-nextjs) while filling in what the official samples leave out — validation, rate limiting, idempotency, logging, and how to surface failure. For the map of the whole cluster, see the [Resend production guide](/en/blog/resend-transactional-email-production-guide).

---

## First, update your mental model to August 2026

Articles written in 2024–2025, and AI-generated code trained on them, contain snippets that today **fail to type-check or break silently**. Fix that first.

| The old understanding (drop it) | Correct as of August 2026 |
|---|---|
| `resend` is on v3 / v4 | The npm latest is **6.18.1** (published 2026-07-28) and `engines` is `node >= 20`. The code here is verified against the **6.4.1** type definitions installed on this site |
| Errors are `{ message, name }` | `ErrorResponse` is **`{ message, statusCode, name }`**, and the whole response now carries `headers`, so it is `{ data, error, headers }` |
| An `Authorization` header is enough | **`User-Agent` is mandatory.** Requests without one are rejected with **403 / error code 1010** before they ever reach the API (the SDK and CLI set it for you) |
| `idempotencyKey` goes in the payload | It is an **option in the second argument**. In the payload it is a type error, and even if it slips through it is dropped before the request is sent |
| The SDK never throws | Not for HTTP or network failures. But the **constructor** and the **`react:` rendering path** do throw |
| Recipient lists live in `audiences` | The type definitions mark it **`@deprecated`**. New code should use `segments` |

Some entries even disagree between surfaces: for `invalid_from_address`, the official Errors page says **422** while the SDK 6.4.1 constant table says **403**. I cannot tell you which is authoritative, so the error classification later in this article treats **`error.name` as primary and `statusCode` as secondary**.

---

## Ground rule: never call the Resend API from the browser

Call `resend.emails.send()` from a client component and the browser says this:

```text
Access to XMLHttpRequest at 'https://api.resend.com/emails'
from origin 'http://localhost:3000' has been blocked by CORS policy:
Response to preflight request doesn't pass access control check:
No 'Access-Control-Allow-Origin' header is present on the requested resource.
```

The official knowledge base devotes a whole page to this, and its solution is not a proxy or an extra header — it is one line: send the emails **server-side** so you do not expose your API keys and avoid CORS issues.

In other words, `api.resend.com` **deliberately** emits no `Access-Control-Allow-Origin`. Because a browser can never complete the request, **embedding an API key in client JavaScript is a design that cannot work in the first place**. The CORS error beginners try to "fix" is the guardrail telling them the key would have been public.

One Next.js-specific warning, which is my own guidance rather than a doc quote: environment variables prefixed with `NEXT_PUBLIC_` are **inlined into the client bundle** at build time. Name it `NEXT_PUBLIC_RESEND_API_KEY` and the key ships as a static asset that anyone can read with view-source. Resend's own key-handling guide says never to expose a key "in the browser or other client-side code." Name the variable **`RESEND_API_KEY`**, full stop.

---

## The minimal Route Handler (faithful to the official sample)

The App Router version from the official Next.js quickstart is the starting point.

```ts
// app/api/send/route.ts
import { EmailTemplate } from '../../../components/email-template';
import { Resend } from 'resend';

const resend = new Resend(process.env.RESEND_API_KEY);

export async function POST() {
  try {
    const { data, error } = await resend.emails.send({
      from: 'Acme <onboarding@resend.dev>',
      to: ['delivered@resend.dev'],
      subject: 'Hello world',
      react: EmailTemplate({ firstName: 'John' }),
    });

    if (error) {
      return Response.json({ error }, { status: 500 });
    }

    return Response.json(data);
  } catch (error) {
    return Response.json({ error }, { status: 500 });
  }
}
```

Three things to notice. What you pass to `react:` is a **function call, not JSX** (`EmailTemplate({ ... })`) — an explicit official rule. The result is branched on `{ data, error }`. And **there is still a `try/catch`** — because, as covered below, this combination can genuinely throw.

Note too that the Pages Router version on the same page returns **400** on `error` while the App Router version returns **500**. The official docs **disagree with themselves inside one page**, so decide for yourself instead of copying either.

### Seven reasons it works but is not production-ready

| What is missing | What happens in production |
|---|---|
| No auth, no rate limiting | Anyone can `POST /api/send`. You have shipped a public email-sending API, and your quota and domain reputation get burned |
| No input validation | Unexpected types and lengths flow straight into the send payload |
| `error` is returned verbatim to the client | Resend's `message` and `statusCode` leak to the browser (the Vercel Functions sample returns the entire response, which leaks even more) |
| No idempotency key | A network retry or a double-click sends **the same email twice** |
| `new Resend(...)` at module scope | Without a key the constructor throws at import time and takes the build with it |
| No `text` part | Worse for plain-text clients and spam filters. One is auto-generated from `html`, but you do not control it |
| No logs | You know it failed, but not **how many attempts, under which error name, from which sender** |

---

## Building the production version

The snippets below are quoted from `app/api/contact/route.ts` as it actually runs on this site. I have kept only the parts where a decision was made.

### 1. Close the boundary with Zod, and share the schema with the client

Validate once, at the entrance to the route handler, with Zod. The part that matters is **where the schema lives**. If the form and the server each own a schema, one of them will eventually be updated alone and break.

```ts
// lib/contact-schema.ts — the single definition shared by client and server
export function buildContactSchema(m: ContactMessages) {
  return z.object({
    name: z.string().min(2, { message: m.nameMin }).max(50, { message: m.nameMax }),
    email: z.string().email({ message: m.email }),
    projectType: z.enum(PROJECT_TYPES, { message: m.projectType }),
    message: z.string().min(20, { message: m.messageMin }).max(2000, { message: m.messageMax }),
    // Honeypot: real users never see this field, so non-empty means bot.
    website: z.string().max(0, { message: "invalid" }).optional().or(z.literal("")),
    // Time spent on the form (ms). Bots submit almost instantly.
    elapsedMs: z.number().int().nonnegative().optional(),
  });
}
```

The same file also owns the label maps (`PROJECT_TYPE_LABELS` and friends), so the labels in the email body, the options in the form, and the schema enum **stay in sync in one place**. Spread across three files, you eventually ship an email containing the raw value `dx-assessment`.

One lesson from experience: I originally declared the optional radio groups as `z.enum(...).optional()`. React Hook Form reports an **unchecked radio group as `null`**, so every visitor who skipped an optional field had their submission silently rejected. Switching to `nullish()` fixed it. Decide, at the boundary, whether "not filled in" is `undefined` or `null`.

### 2. Rate limiting: return 429 with `Retry-After` and `X-RateLimit-*`

```ts
const limiter = getLimiter({ prefix: "contact", max: 5, windowMs: 10 * 60 * 1000 });

const limit = await limiter.check(getClientKey(request, "contact"));
if (!limit.allowed) {
  log("warn", "rate_limited");
  return NextResponse.json(
    { error: "Please wait a moment and try again" },
    {
      status: 429,
      headers: {
        // A 429 with no seconds attached never tells the client when it may
        // retry, so it just gets resent immediately and achieves nothing.
        "Retry-After": String(Math.max(1, Math.ceil((limit.resetAt - Date.now()) / 1000))),
        "X-RateLimit-Limit": "5",
        "X-RateLimit-Remaining": "0",
      },
    },
  );
}
```

Guard your own entrance, but keep **Resend's limit** in mind too. Per the official Usage Limits page the default is **10 requests per second per team**, applied across every API key on the team, with no separate burst allowance — the eleventh request in the same second gets a 429. Responses carry `ratelimit-limit`, `ratelimit-remaining`, `ratelimit-reset`, and `retry-after`.

Since v6 the SDK result also carries `headers`, so `const { data, error, headers } = await resend.emails.send(...)` plus `headers?.["ratelimit-remaining"]` (header names are lowercase) lets you spot exhaustion **before** the 429 rather than after. No official page demonstrates this pattern, so treat it as my suggestion.

The rate limiter itself is covered separately in the [serverless rate limiting guide for Next.js](/en/blog/nextjs-serverless-rate-limiting-vercel-guide).

### 3. Absorb spam rather than rejecting it

```ts
function isSpam(payload: ContactRequestData): boolean {
  if (payload.website && payload.website.length > 0) return true;   // honeypot
  if (typeof payload.elapsedMs === "number" && payload.elapsedMs < 2_500) return true;
  return false;
}

if (isSpam(data)) {
  log("warn", "spam_filtered");
  // Returning 400 teaches the bot it was detected and invites evasion.
  // Looking successful and dropping the message gives it nothing.
  return NextResponse.json({ success: true, messageId: null }, { status: 200 });
}
```

A honeypot (the `website` field, invisible to real users) plus a minimum dwell time removes most automated form spam. The important part is **returning 200**.

### 4. Stop injection into the HTML email body

The moment you build a string and hand it to `html:`, you are doing **raw HTML concatenation**, not templating. Interpolate user input directly and link injection or attribute escape becomes possible.

```ts
// lib/html-escape.ts — the one implementation shared by all four Resend routes
const HTML_ESCAPE_MAP: Readonly<Record<string, string>> = Object.freeze({
  "&": "&amp;",
  "<": "&lt;",
  ">": "&gt;",
  '"': "&quot;",
  "'": "&#39;",
});

export function escapeHtml(input: string): string {
  return input.replace(/[&<>"']/g, (c) => HTML_ESCAPE_MAP[c] ?? c);
}

// At the call site: every user-supplied string goes through it, no exceptions
`<td><strong>${escapeHtml(data.name)}</strong></td>`;
```

Keep the escaper in one place instead of copy-pasting it. And "it is only an email, so the impact is small" is wrong: the destination is **your own inbox**, and that is where the HTML gets opened.

### 5. Initialise the Resend client lazily

This one is Next.js-specific and takes the whole build down when you hit it. The SDK constructor **throws** when it cannot resolve a key.

```ts
// ❌ Module scope: throws at import time wherever RESEND_API_KEY is unset
const resend = new Resend(process.env.RESEND_API_KEY);

// ✅ Lazy: the exception happens during a request and is observable as a 500
let resendClient: Resend | null = null;
function getResend(): Resend {
  if (!resendClient) {
    const apiKey = process.env.RESEND_API_KEY;
    if (!apiKey) throw new Error("RESEND_API_KEY is not configured");
    resendClient = new Resend(apiKey);
  }
  return resendClient;
}
```

`next build` loads your route-handler modules. With `new Resend(...)` at module scope, **the build fails** in any CI or preview environment without the key. Lazily, the same situation costs you one 500 on one request — and it shows up in the logs. Resend's own example repository guards with an explicit `if (!process.env.RESEND_API_KEY) throw new Error(...)` for the same reason.

The SDK will also read `process.env.RESEND_API_KEY` automatically if you call `new Resend()` with no argument. Convenient, but it erases from the code which variable you depend on, so I pass it explicitly.

### 6. `idempotencyKey` is the second argument (in the first, it vanishes silently)

This is the single most important line in the article. **The correct form is the second argument.**

```ts
await resend.emails.send(
  {
    from: 'Acme <onboarding@resend.dev>',
    to: ['delivered@resend.dev'],
    subject: 'hello world',
    html: '<p>it works!</p>',
  },
  {
    idempotencyKey: 'welcome-user/123456789',
  },
);
```

The "AI prompt" block at the top of each quickstart, however, puts `idempotencyKey` **inside the payload**. That does not satisfy the `CreateEmailOptions` type. And even if you bypass type checking, the SDK function that builds the request body (`parseEmailToApiOptions`) is a **whitelist**: everything except `from`, `to`, `cc`, `bcc`, `subject`, `html`, `text`, `reply_to`, `scheduled_at`, `headers`, `tags`, `attachments`, `template`, and `topic_id` is discarded. **Idempotency disappears with no error and no warning** — the classic way AI-written code starts double-sending in production.

The bare minimum of the spec: idempotency keys are supported on `POST /emails` and `POST /emails/batch` only, must be **1–256 characters**, live for **24 hours**, and the recommended format is `<event-type>/<entity-id>`, e.g. `welcome-user/123456789`. Three errors can come back — `invalid_idempotency_key` (400) plus two 409s (`invalid_idempotent_request` and `concurrent_idempotent_requests`) whose meanings and correct responses are exact opposites — and the classification table and retry design for them live in the [idempotency, retries and error handling guide](/en/blog/resend-idempotency-retry-error-handling-reliability-guide).

If you accept an `Idempotency-Key` header from the client, normalise it rather than passing it through.

```ts
function safeIdempotencyKey(raw: string | null): string {
  // Replace a malformed key instead of rejecting it — losing a lead to a
  // formatting mistake in a header is not a trade worth making.
  const cleaned = (raw ?? "").replace(/[^A-Za-z0-9._:-]/g, "").slice(0, MAX_IDEMPOTENCY_KEY_LENGTH);
  return cleaned.length > 0 ? cleaned : crypto.randomUUID();
}
```

Putting `X-Entity-Ref-ID` in the payload `headers` is a separate feature for preventing Gmail threading — it **does not deduplicate**. The payments equivalent of all this is in the [Stripe production guide](/en/blog/stripe-payments-production-guide-webhooks-idempotency-subscriptions).

### 7. `replyTo` is the enquirer, not the sender

This is the most common design mistake in contact notifications.

```ts
client.emails.send(
  {
    from: configuredFrom,          // your own verified domain
    to: [recipient],               // your own inbox
    replyTo: data.email,           // ← the address of the person who submitted
    subject: `【${projectTypeLabel}】${data.name}様からのお問い合わせ`,
    html,
    text,
  },
  { idempotencyKey },
);
```

Setting `from` to the enquirer's address is a non-starter — you cannot authenticate it on your domain, so deliverability drops and it looks like spoofing. But setting `replyTo` to your own address is a surprisingly common variant, and it means that hitting "reply" in your inbox **replies to yourself**. `replyTo` answers "who should receive a reply to this notification", not "who sent it".

### 8. Decide `maxDuration` and the send timeout together

```ts
export const dynamic = "force-dynamic";
/**
 * The retry loop can issue several provider round-trips plus backoff. Without an
 * explicit budget, a slow provider gets the function killed mid-retry — which
 * takes the `delivery_failed` log line with it and makes the incident invisible.
 */
export const maxDuration = 25;

/** Per-send ceiling, sized so the worst case still fits inside `maxDuration`. */
const SEND_TIMEOUT_MS = 8_000;
```

The point is to make "**function budget > worst case of the whole retry chain**" true on purpose. Set only one of them and the platform kills you before you record the failure. A provider that **hangs instead of erroring** has to be cut off yourself with `Promise.race` — the SDK has no timeout option. The function budget itself depends on your host and plan, so check yours in the [Vercel Functions guide](/en/blog/vercel-functions-fluid-compute-streaming-cron-guide).

### 9. Logs: single-line JSON, no PII

```ts
function log(level: "info" | "warn" | "error", phase: string, fields: Record<string, unknown> = {}): void {
  // Multi-argument console.* output gets collapsed or truncated by log viewers,
  // and the detail you most want during an incident disappears. One event per
  // JSON line stays searchable and parseable wherever you read it.
  console[level](JSON.stringify({ route: "contact", phase, ...fields }));
}

log("error", "delivery_failed", {
  kind: result.kind,           // retryable / sender_rejected / fatal
  errorName: result.errorName, // Resend's error.name
  statusCode: result.statusCode,
  attempts: result.attempts.length,
});
```

Keep the fields to **enums, status codes, and counts**. Never names, addresses, or message bodies. Logs flow to third-party services and are retained for a long time.

One Next.js trap: enabling `compiler.removeConsole` naively in `next.config.ts` can **erase every server log from the production build**. On this site I narrowed it to `{ exclude: ["error", "warn", "info"] }`. If you plan to rely on these logs during an incident, verify once with `next build && next start` that the output actually survives.

### Classify errors by name

`error.name` is a string-literal union. Classify on the **name**, not the status code — as noted at the top, some entries disagree between the docs and the SDK. On this site `lib/email-delivery.ts` holds a map from error name to `retryable` / `sender_rejected` / `fatal` and only falls back to `statusCode` when the name is not in the map. (The contents of that map, and the reasoning behind each classification, belong to the [idempotency, retries and error handling guide](/en/blog/resend-idempotency-retry-error-handling-reliability-guide).)

Two things matter on the route-handler side. First, seeing an error-code constant in the type definitions does **not** mean you can `import` it: in the latest version that constant is gone from the published bundle entirely, and the only error-related public export is `type ErrorResponse`. Owning your own map is safer. Second, keep `sender_rejected` — "the payload or the sender itself was refused, so retrying is pointless" — as its own category. That is exactly the distinction I learned from the DNS outage: with the verified domain broken, a **one-time sender fallback** to `onboarding@resend.dev` is what saved the leads.

---

## Sending from a Server Action

There is no Server Actions sample on the Resend documentation pages — search the entire docs corpus for `use server` and you get zero hits. But **there is one in the official example app**: `contact-form`, under `nextjs-resend-examples/typescript` in the `resend/resend-examples` repository and described as "Contact form with batch send via Server Actions", whose page states that this is "the recommended approach for forms in Next.js 16." It uses `batch.send()` to send two emails — a confirmation to the submitter and a notification to the site owner — in a single request.

```ts
'use server';

export async function submitContactForm(
  prevState: ContactFormState,
  formData: FormData,
): Promise<ContactFormState> {
  // Reuse the same Zod schema as the Route Handler. Two boundaries, one
  // definition of what valid input means.
  const parsed = contactFormSchema.safeParse(Object.fromEntries(formData));
  if (!parsed.success) return { success: false, error: 'Please check your input' };

  const { data, error } = await resend.batch.send([
    { from, to: [parsed.data.email], subject: 'We received your message', react: Confirmation(parsed.data) },
    { from, to: [ownerAddress], subject: 'New contact form submission', react: Notification(parsed.data) },
  ]);

  if (error) {
    // Never put error.message on screen. Log it; return fixed copy to the user.
    console.error(JSON.stringify({ route: 'contact-action', phase: 'failed', name: error.name }));
    return { success: false, error: 'Sending failed. Please try again shortly' };
  }

  // ⚠️ batch.send nests one level deeper than emails.send: data.data holds the ids.
  console.info(JSON.stringify({ phase: 'sent', count: data?.data?.length ?? 0 }));
  return { success: true, error: null };
}
```

`batch.send()` carries **up to 100 emails** per request and counts as **one request** against the rate limit. Attachments are not supported, and under the default validation mode (`strict`) **a single invalid entry fails the whole request**. With `batchValidation: 'permissive'` the valid ones go out and the failures come back as `errors: { index, message }[]`.

| Consideration | Route Handler | Server Action |
|---|---|---|
| Caller | fetch, external systems, other services | a form in the same app |
| HTTP semantics | can return 429 and `Retry-After` directly | cannot; express it in the returned state |
| With JavaScript disabled | does not work | works as a `form action` |
| Reproducing from outside | hit it with `curl`, easy to attach an idempotency key | assumes the form path |
| Best suited to | public APIs, webhooks, anything that gets retried | in-app form submission |

I chose the Route Handler on this site. I wanted the rate-limit outcome expressed as a standard **429 with `Retry-After`**, and I wanted to be able to hit production directly with `curl` while diagnosing an incident. The form-state side of a Server Action (`useActionState`) is covered in the [React Hook Form and Server Actions guide](/en/blog/react-hook-form-nextjs-server-actions-useactionstate-guide).

---

## Composing the HTML with React Email

Pass a React component to `react:` and the SDK renders it to HTML internally. There is an important change here since v5.0.0: **`@react-email/render` became an optional peer dependency**, imported dynamically at the point of use. If it is not installed, the SDK throws a **plain `Error`** instead of returning `{ data, error }`.

```ts
// from the SDK bundle (dist/index.js). One of the exceptions to "the SDK never throws".
try {
  ({ render: render2 } = await import("@react-email/render"));
} catch (e) {
  throw new Error(
    "Failed to render React component. Make sure to install `@react-email/render` or `@react-email/components`."
  );
}
```

So an implementation that uses `react:` **needs a `try/catch`** — which is precisely why the official Next.js sample has one. React Email 6.0 (2026-04-16) then **changed the package layout**: `@react-email/components` and the individual packages are gone, and both the components and `render` now come from the single **`react-email`** package. `renderAsync` was removed in 5.0, and `render` itself is `async`.

My recommendation — this is a practical judgement, not an official one — is to render to an HTML string yourself rather than delegating to `react:`.

```ts
import { render, toPlainText } from 'react-email';

const html = await render(WelcomeEmail({ name })); // render is async
const text = toPlainText(html);                    // toPlainText is synchronous

await resend.emails.send({ from, to, subject, html, text }, { idempotencyKey });
```

Three reasons. You no longer depend on the SDK's dynamic import, so a dependency-resolution mistake surfaces **at build time rather than at send time**. You can author `text` explicitly (omit it and Resend generates one from `html`, with no control over the result; pass an empty string to opt out of generation entirely). And you can snapshot-test the rendered output.

Template design — Resend-hosted Templates, the triple-brace `{{{VAR}}}` variable syntax, and the draft-to-published lifecycle — is covered in the [templates and React Email design guide](/en/blog/resend-templates-variables-react-email-design-guide). The payload limits are:

| Item | Limit |
|---|---|
| `to` recipients | Max 50 |
| Attachments | 40MB total per email after Base64 encoding. Not supported in batch sends |
| `tags` `name` and `value` | ASCII letters, digits, underscores, and dashes only, max 256 characters each. **No Japanese, no dots, no spaces** |
| Idempotency key | 1–256 characters, valid for 24 hours |
| Scheduled sends | Up to 30 days in advance |

That tag charset rule bites hard in Japanese: `tags: [{ name: "種別", value: "問い合わせ" }]` is rejected.

---

## Surfacing failure (UX and accessibility)

Hardening the server is pointless if the screen just sits there when the button is pressed. I broke the contact form on this very site for **108 days** without noticing. The cause was the `null`-rejecting Zod schema described earlier, but **the reason I did not notice was different**: the error was never rendered anywhere on screen. Since then I treat these three as mandatory.

**1. Announce errors through `aria-live`.** Put a live region near the submit button and write both success and failure into it. Rendering errors only at the bottom of the form reaches neither screen-reader users nor anyone scrolled above it.

```tsx
<p role="status" aria-live="polite" className="min-h-6 text-sm">
  {state.error ?? (state.success ? "Message sent. I will reply within two business days" : "")}
</p>
```

**2. Block double submission.** Disabling the button on `isSubmitting` is the floor; you need it **and** the server-side idempotency key. A disabled button does not stop a network-level retry.

**3. Always show which field is at fault.** If a validation error targets a field that is not rendered, `handleSubmit` stops silently. "Nothing submits and no error appears" is almost always this. Wire up an on-invalid handler and emit it as an analytics event, and you will not lose another 108 days.

---

## Verifying locally and in production

Start with a smoke test. For the 429 path, add `-i` and fire the request as many times as the limit allows, then check with your own eyes that `Retry-After` comes back in seconds and `X-RateLimit-Remaining` counts down.

```bash
curl -X POST http://localhost:3000/api/contact \
  -H 'Content-Type: application/json' \
  -d '{"name":"Test","email":"t@example.com","projectType":"project","message":"twenty or more characters of body text here"}'
```

Send to Resend's test addresses. **Do not invent your own dummy address** — mail to a non-existent domain damages your bounce rate.

| Address | Behaviour reproduced |
|---|---|
| `delivered@resend.dev` | Normal delivery |
| `bounced@resend.dev` | Bounce |
| `complained@resend.dev` | Spam complaint |
| `suppressed@resend.dev` | Blocked by the suppression list |

Receiving those bounce and complaint events in your app is covered in the [webhooks and signature verification guide](/en/blog/resend-webhooks-signature-verification-bounce-complaint-guide).

Before your domain is verified, while `from` is `onboarding@resend.dev`, **you can only send to the account owner's own address**. Anything else returns a 403 with:

```text
You can only send testing emails to your own email address (your-email-address@domain.com).
To send emails to other recipients, please verify a domain at resend.com/domains, and change
the `from` address to an email using this domain.
```

Skip past this into a production domain and you will get stuck on DNS record placement. That is exactly where I produced the 502 outage: SPF, DKIM, MX, and DMARC were all on the apex rather than the sending subdomain. Correct placement is covered in the [domain authentication and deliverability guide](/en/blog/resend-domain-authentication-spf-dkim-dmarc-deliverability-guide).

Two closing tricks. First, **checking production without actually sending mail**: in this implementation the spam gate sits **after Zod validation and before the send**, so a well-formed body containing `"elapsedMs": 0` reaches validation, sends nothing, and returns 200 — enough to confirm routing, validation, and rate limiting. Confirm that ordering in your own code before relying on it. Second, the SDK honours `RESEND_BASE_URL` and `RESEND_USER_AGENT` environment variables to override the base URL and User-Agent, which is handy for pointing tests or CI at a mock server. That behaviour exists **only in the shipped bundle and is not documented on the docs site**, so assume it can break on a version bump.

---

## Pre-production checklist

- [ ] The key is `RESEND_API_KEY` (**no** `NEXT_PUBLIC_` prefix)
- [ ] No `new Resend(...)` at module scope (lazy init)
- [ ] Zod validation at the entrance, schema **shared** with the client
- [ ] Rate limit returns 429 with `Retry-After` and `X-RateLimit-*`
- [ ] Honeypot plus minimum dwell time, absorbed with a **200** on a hit
- [ ] Every user string entering `html:` passes through `escapeHtml`
- [ ] `idempotencyKey` is in the **second argument to `send()`**
- [ ] `replyTo` is the enquirer's address
- [ ] `text:` is supplied explicitly
- [ ] `maxDuration` and the per-send timeout are **consistent**
- [ ] Error classification keys off `error.name` (`statusCode` is secondary)
- [ ] The Resend `error` object is never returned to the client
- [ ] Logs are single-line JSON, carry no PII, and survive the production build
- [ ] The UI has an `aria-live` error announcement and double-submit protection
- [ ] You reproduced 200, 400, and 429 with `curl`

---

## Summary

Calling Resend from Next.js takes ten lines. What this article added is the judgement that lives **around** those ten lines.

1. **Never call it from the browser** — CORS is the spec, and the guardrail protecting your key
2. **One Zod schema at the boundary** — share it with the client; never keep two definitions
3. **`idempotencyKey` is the second argument** — in the first it vanishes silently and you double-send
4. **Lazy init** — `new Resend(...)` at module scope breaks the build
5. **Classify and log errors by name** — status codes disagree even within the official docs

Open your own route handler and check just two things: whether `idempotencyKey` is in the second argument, and where `new Resend(...)` sits. Both take five minutes to fix and both will matter during an incident. From here, go to the [Resend production guide](/en/blog/resend-transactional-email-production-guide) for the full picture, or to the [webhooks and signature verification guide](/en/blog/resend-webhooks-signature-verification-bounce-complaint-guide) to follow what happens to a message after it is sent.

> This article is based on the [official Resend documentation](https://resend.com/docs) (Next.js quickstart, API reference, idempotency keys, Usage Limits; as of August 2026) together with the type definitions and shipped bundle of `resend@6.4.1` as installed on this site, restructured with operational judgement from running it in production. Specs, limits, and error codes change, so confirm current values on the official pages before adopting anything in production.
