# Resend × Next.js App Router 実装ガイド：Route Handler と Server Actions で本番品質のメール送信を作る

> Next.js App Router から Resend を本番品質で叩く実装ガイド。ブラウザから呼んではいけない理由、公式の最小サンプルに足りない7点、Zod検証・レート制限・スパム吸収・HTMLエスケープ・遅延初期化・idempotencyKey・構造化ログまでを、稼働中の実コードで解説します。

- 公開日: 2026-08-06
- 著者: 友田 陽大
- タグ: Resend, Next.js, TypeScript, メール配信, React Email, セキュリティ, 信頼性
- URL: https://tomodahinata.com/blog/resend-nextjs-app-router-route-handler-react-email-guide
- カテゴリ: Resend・メール配信
- 総合ガイド: https://tomodahinata.com/blog/resend-transactional-email-production-guide

## 要点

- Resend API をブラウザから呼ぶ設計は成立しない。api.resend.com は Access-Control-Allow-Origin を返さないため CORS で止まり、公式は『サーバーサイドで送れ』と明記している。CORS エラーは直す対象ではなく、APIキーが公開される前に止めるガードレール
- 公式の Next.js 最小サンプルは動くが本番には足りない。入力検証・レート制限・スパム対策・HTMLエスケープ・冪等キー・クライアントへのエラー漏洩対策・遅延初期化の7点が欠けている。この記事は稼働中の app/api/contact/route.ts を教材にその7点を埋める
- idempotencyKey は emails.send の第2引数オプション。公式ドキュメント冒頭の『AI向けプロンプト』ブロックだけが payload 内に書いており、これは SDK の型を通らないうえ、通っても送信時に捨てられる。AIが書いたコードが静かに二重送信になる典型パターン
- モジュールスコープで new Resend(...) してはいけない。キーが無いとコンストラクタが import 時に throw し、Next.js ではビルドごと壊れる。遅延初期化にすれば同じ状況でも 500 として観測できる
- 『SDK は throw しない』は半分だけ正しい。HTTP とネットワーク層は必ず { data, error } で返るが、コンストラクタと react: オプションのレンダリングは例外を投げる。だから公式サンプルにも try/catch がある

---

「動くだけの送信フォーム」と「本番で使える送信フォーム」の差は、成功したときの挙動ではなく、**失敗したときに何が起きるか**で決まります。プロバイダが 5xx を返したとき、ボットが毎秒叩いてきたとき、環境変数を1つ入れ忘れたとき、リトライが走ったとき——そのそれぞれで「リードが消える」「二重送信される」「ビルドごと落ちる」「原因がログに残らない」のどれが起きるかが、本番品質の実体です。

私はこのポートフォリオサイト自体を Resend で本番運用しています。問い合わせフォーム、リードマグネットの配布、7通のメール講座、Stripe 決済後の納品メール——いずれも Next.js の Route Handler から Resend を叩いています。そして一度、**ドメイン認証の DNS レコードを apex（ルート）に誤配置したせいで `/api/contact` が 502 を返し続ける障害**を出しました。その復旧で得た設計が、この記事の後半にそのまま入っています。

この記事は [Resend 公式ドキュメント](https://resend.com/docs/send-with-nextjs)に忠実でありながら、公式サンプルが省いている本番の判断——検証・レート制限・冪等性・ログ・失敗の見せ方——を実コードで埋めます。クラスタ全体の地図は [Resend 本番運用ガイド](/blog/resend-transactional-email-production-guide) を参照してください。

---

## まず知識を2026年8月時点に更新する

2024〜2025年に書かれた記事や、その時期までの知識で生成された AI のコードには、いま**型が通らない・静かに壊れる**ものが混ざっています。最初にここを直します。

| 古い理解（捨てる） | 2026年8月時点の正しい理解 |
|---|---|
| `resend` は v3 / v4 系 | npm の最新は **6.18.1**（2026-07-28 公開）、`engines` は `node >= 20`。本稿のコードは本サイトにインストール済みの **6.4.1** の型定義で検証 |
| エラーは `{ message, name }` | `ErrorResponse` は **`{ message, statusCode, name }`**。レスポンス全体にも `headers` が付き `{ data, error, headers }` になった |
| `Authorization` ヘッダだけで通る | **`User-Agent` が必須**。無いリクエストは API に到達する前に **403 / エラーコード 1010** で弾かれる（SDK と CLI は自動で付ける） |
| `idempotencyKey` は payload に入れる | **第2引数のオプション**。payload に書くと型エラーになり、仮に通っても送信時に捨てられる |
| SDK は例外を投げない | HTTP・ネットワーク層は投げない。ただし**コンストラクタ**と **`react:` のレンダリング**は投げる |
| 宛先リストは `audiences` | 型定義上 **`@deprecated`**。新規実装は `segments` を使う |

`invalid_from_address` のように、**公式 Errors ページと SDK の定数でステータスコードが食い違う**項目もあります（ドキュメントは 422、SDK 6.4.1 の定数表は 403）。どちらが正しいと断定はできないので、後述のエラー分類では **`error.name` を主、`statusCode` を従**として扱います。

---

## 大前提：Resend API をブラウザから呼んではいけない

クライアントコンポーネントから `resend.emails.send()` を呼ぶと、ブラウザはこう言います。

```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.
```

公式ナレッジベースはこの現象に1ページを割いていますが、示される解決策はプロキシでもヘッダ追加でもなく一行です——「APIキーを露出させず CORS を避けるため、**サーバーサイドで**送信してください」。

つまり `api.resend.com` は**意図的に `Access-Control-Allow-Origin` を返していません**。ブラウザからは絶対に完了できないということは、**APIキーをクライアント JS に埋め込む設計が最初から成立しない**ということです。初心者が「直そう」とするこの CORS エラーは、キーが公開される前に止めてくれているガードレールです。

Next.js 固有の注意を1つ足します（公式ドキュメントには書かれていない、私からの指摘です）。`NEXT_PUBLIC_` 接頭辞の付いた環境変数はビルド時にクライアントバンドルへ**インライン展開**されます。`NEXT_PUBLIC_RESEND_API_KEY` と名付けた瞬間、キーは静的アセットとして配信され `view-source` で読めます。公式のキー取り扱いガイドも「ブラウザやクライアントサイドのコードに露出させないでください」と明記しています。変数名は必ず **`RESEND_API_KEY`** にしてください。

---

## 最小の Route Handler（公式サンプルに忠実）

公式 Next.js クイックスタートの App Router 版が出発点です。

```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 });
  }
}
```

読みどころは3つ。`react:` に渡すのは **JSX ではなく関数呼び出し**（`EmailTemplate({ ... })`）で、これは公式が明示しているルールです。`{ data, error }` を分岐しています。そして**それでも `try/catch` がある**——後述するとおり、この組み合わせでは例外が飛びうるからです。

なお同じページの Pages Router 版は `error` のとき **400** を返します。App Router 版は **500** です。**同一ページ内で公式の見解が割れている**ので、真似るのではなく自分で決めてください。

### 動くが、本番には足りない7つの理由

| 不足点 | 本番で起きること |
|---|---|
| 認証もレート制限もない | 誰でも `POST /api/send` を叩ける。実質「公開メール送信API」になり、送信枠とドメイン評判を焼かれる |
| 入力検証がない | 想定外の型・長さがそのまま送信ペイロードに入る |
| `error` をそのままクライアントに返す | Resend の `message` と `statusCode` がブラウザに漏れる（Vercel Functions 版のサンプルはレスポンス全体を返しており露出はさらに大きい） |
| 冪等キーがない | ネットワークリトライやユーザーの二度押しで**同じメールが2通**飛ぶ |
| モジュールスコープで `new Resend(...)` | キー未設定時にコンストラクタが import 時に throw し、ビルドごと壊れる |
| `text` を添えていない | プレーンテキスト派のクライアントとスパムフィルタに不利。`html` から自動生成はされるが内容を制御できない |
| ログがない | 失敗したことは分かるが、**何回試して・どのエラー名で・どの送信者で**失敗したかが残らない |

---

## 本番版 Route Handler を組み立てる

以降の断片は、このサイトで実際に動いている `app/api/contact/route.ts` からの引用です。判断が入っている箇所だけを抜き出します。

### 1. 境界を Zod で閉じる（スキーマはクライアントと共有する）

検証は「route handler の入口で一度だけ、Zod で」に統一します。重要なのは**スキーマの置き場所**です。フォームとサーバーが別々のスキーマを持つと、必ず片方だけ更新されて壊れます。

```ts
// lib/contact-schema.ts — クライアントとサーバーが共有する唯一の定義
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 }),
    // ハニーポット: 実ユーザーには見えないので、空でなければボット。
    website: z.string().max(0, { message: "invalid" }).optional().or(z.literal("")),
    // フォーム滞在時間(ms)。ボットはほぼ即座に送信する。
    elapsedMs: z.number().int().nonnegative().optional(),
  });
}
```

同じファイルがラベルの対応表（`PROJECT_TYPE_LABELS` など）も持ちます。メール本文の日本語ラベル・フォームの選択肢・スキーマの enum が**1箇所で同期する**からです。ここが3箇所に散ると、メールに `dx-assessment` という生の値が載る事故が起きます。

実体験を1つ。任意項目のラジオグループを当初 `z.enum(...).optional()` にしていたのですが、react-hook-form は**未選択のラジオグループを `null` で報告する**ため、任意項目を飛ばした人の送信が黙って弾かれ続けました。`nullish()` に変えて解決しています。境界の型は「未入力」を `undefined` と `null` のどちらで表現するかまで詰めてください。

### 2. レート制限：429 に `Retry-After` と `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: "しばらく時間を置いてから再度お試しください" },
    {
      status: 429,
      headers: {
        // 秒数を返さない 429 は「いつ再試行してよいか」を伝えないので、
        // 結局すぐ再送されて意味がない。
        "Retry-After": String(Math.max(1, Math.ceil((limit.resetAt - Date.now()) / 1000))),
        "X-RateLimit-Limit": "5",
        "X-RateLimit-Remaining": "0",
      },
    },
  );
}
```

自分の入口だけでなく **Resend 側の上限**も意識してください。公式の Usage Limits ページによると、レート制限は既定で**チーム単位・毎秒10リクエスト**、チーム内の全 APIキーを横断して適用され、バースト許容枠はありません。同一秒内の11本目は 429 です。レスポンスには `ratelimit-limit` / `ratelimit-remaining` / `ratelimit-reset` / `retry-after` が返ります。

v6 の戻り値には `headers` が生えたので、`const { data, error, headers } = await resend.emails.send(...)` と受けて `headers?.["ratelimit-remaining"]`（ヘッダ名は小文字）を観測すれば、429 を「起きた後」ではなく「起きる前」に検知できます。ただしこのパターンを示す公式ページは見つからないので、私からの提案として扱ってください。

サーバーレスでのレート制限の実装そのものは [Next.js サーバーレスのレート制限ガイド](/blog/nextjs-serverless-rate-limiting-vercel-guide) に分けています。

### 3. スパムは「弾く」のではなく「吸収する」

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

if (isSpam(data)) {
  log("warn", "spam_filtered");
  // 400 を返すとボットは「検知された」と学習して回避を試みる。
  // 成功したように見せて捨てるほうが、相手に情報を与えない。
  return NextResponse.json({ success: true, messageId: null }, { status: 200 });
}
```

ハニーポット（実ユーザーに見えない `website` フィールド）と最小滞在時間の2枚で、フォーム宛の自動投稿はかなり落ちます。ポイントは**200 を返す**ことです。

### 4. HTML メール本文へのインジェクションを止める

`html:` に文字列を組み立てて渡す以上、そこはテンプレートエンジンではなく**生の HTML 連結**です。ユーザー入力をそのまま入れれば、リンクの差し込みや属性の脱出が成立します。

```ts
// lib/html-escape.ts — 4つの Resend ルートが共有する唯一の実装
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);
}

// 使う側: 例外なく全てのユーザー文字列を通す
`<td><strong>${escapeHtml(data.name)}</strong></td>`;
```

エスケープ関数はコピペせず1箇所に置いてください。「メールだから実害は小さい」も誤りです。届く先は**自分の受信箱**であり、そこで開かれる HTML です。

### 5. Resend クライアントは遅延初期化する

これは Next.js 固有の、踏むとビルドごと落ちる罠です。SDK のコンストラクタはキーが解決できないと **throw** します。

```ts
// ❌ モジュールスコープ: RESEND_API_KEY 未設定の環境では import 時点で例外
const resend = new Resend(process.env.RESEND_API_KEY);

// ✅ 遅延初期化: 例外はリクエスト処理中に起き、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` は route handler のモジュールを読み込みます。モジュールスコープで `new Resend(...)` していると、キーの無い CI やプレビュー環境で**ビルドが失敗**します。遅延初期化なら同じ状況でも「そのリクエストが 500 になる」だけで済み、ログにも残ります。Resend 公式のサンプルリポジトリが `if (!process.env.RESEND_API_KEY) throw new Error(...)` という明示的なガードを置いているのも同じ問題への対処です。

なお SDK は引数なしの `new Resend()` でも `process.env.RESEND_API_KEY` を自動で読みます。便利ですが、どの環境変数に依存しているかがコードから消えるので、私は明示的に渡す側を選んでいます。

### 6. `idempotencyKey` は第2引数（第1引数に書くと黙って消える）

この記事でいちばん重要な一行です。**正しい書き方は第2引数**です。

```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',
  },
);
```

一方、各クイックスタート冒頭の「AI向けプロンプト」ブロックだけが `idempotencyKey` を **payload の中**に書いています。これは `CreateEmailOptions` の型を通りません。そして仮に型検査を迂回しても、SDK がリクエストボディを組み立てる関数（`parseEmailToApiOptions`）は**ホワイトリスト方式**で、`from` / `to` / `cc` / `bcc` / `subject` / `html` / `text` / `reply_to` / `scheduled_at` / `headers` / `tags` / `attachments` / `template` / `topic_id` 以外を捨てます。**エラーも警告も出ずに冪等性だけが消える**——AI に書かせたコードが本番で二重送信を起こす典型パターンです。

仕様は最小限だけ押さえておきます。冪等キーは `POST /emails` と `POST /emails/batch` のみ対応、**1〜256文字**、**有効期間24時間**、推奨フォーマットは `<event-type>/<entity-id>`（例 `welcome-user/123456789`）です。返りうるエラーは `invalid_idempotency_key`（400）と、意味も対処も正反対な2種類の 409（`invalid_idempotent_request` / `concurrent_idempotent_requests`）の3つで、それぞれの分類表とリトライ設計は [冪等性・リトライ・エラー処理ガイド](/blog/resend-idempotency-retry-error-handling-reliability-guide) に譲ります。

クライアントから `Idempotency-Key` ヘッダを受け取る設計にするなら、そのまま渡さず整形します。

```ts
function safeIdempotencyKey(raw: string | null): string {
  // 不正なキーは「拒否」ではなく「置換」する。キーの形式ミスでリードを
  // 失うのは割に合わない。
  const cleaned = (raw ?? "").replace(/[^A-Za-z0-9._:-]/g, "").slice(0, MAX_IDEMPOTENCY_KEY_LENGTH);
  return cleaned.length > 0 ? cleaned : crypto.randomUUID();
}
```

payload の `headers` に `X-Entity-Ref-ID` を入れるのは Gmail のスレッド化を防ぐ別機能で、**重複排除はしません**。決済側の同じ話題は [Stripe 本番運用ガイド](/blog/stripe-payments-production-guide-webhooks-idempotency-subscriptions) を参照してください。

### 7. `replyTo` は「送信者」ではなく「相談者」

問い合わせ通知でいちばん多い設計ミスがこれです。

```ts
client.emails.send(
  {
    from: configuredFrom,          // 自分の検証済みドメイン
    to: [recipient],               // 自分の受信箱
    replyTo: data.email,           // ← フォームを送った相談者のアドレス
    subject: `【${projectTypeLabel}】${data.name}様からのお問い合わせ`,
    html,
    text,
  },
  { idempotencyKey },
);
```

`from` を相談者のアドレスにするのは論外（自分のドメインで認証できず到達率が落ち、なりすまし扱いされます）ですが、`replyTo` を自分のアドレスにする実装も珍しくありません。そうすると受信箱で「返信」を押したとき**自分に返信します**。`replyTo` は「この通知に返信したら誰に届いてほしいか」であって、送信元の表明ではありません。

### 8. `maxDuration` と送信タイムアウトはセットで決める

```ts
export const dynamic = "force-dynamic";
/**
 * リトライループは複数回のプロバイダ往復＋バックオフを発生させうる。
 * 実行時間を明示しないと、遅いプロバイダに当たったときリトライの途中で
 * 関数が強制終了され、delivery_failed のログごと消える。
 */
export const maxDuration = 25;

/** 1回の送信の上限。最悪ケースでも maxDuration に収まるサイズにする。 */
const SEND_TIMEOUT_MS = 8_000;
```

要点は「**関数の上限 > リトライ全体の最悪ケース**」を明示的に成立させることです。片方だけ決めると、失敗の記録を残す前にプラットフォームに殺されます。プロバイダが**エラーを返さずハングする**ケースは `Promise.race` で自前に切る必要があります（SDK にタイムアウトオプションはありません）。関数の実行時間の上限そのものはホスティング側の設定・プランに依存するので、[Vercel Functions ガイド](/blog/vercel-functions-fluid-compute-streaming-cron-guide) で自分の値を確認してください。

### 9. ログは単一行 JSON・PII なし

```ts
function log(level: "info" | "warn" | "error", phase: string, fields: Record<string, unknown> = {}): void {
  // 複数引数の console.* はホストのログビューアで結合・省略されることがあり、
  // 障害時にいちばん欲しい詳細が消える。1イベント＝1行の JSON にしておけば
  // どこで見ても検索・パースできる。
  console[level](JSON.stringify({ route: "contact", phase, ...fields }));
}

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

**フィールドは enum・ステータスコード・件数だけ**にします。氏名・メールアドレス・本文は絶対に入れません。ログは第三者のサービスへ流れ、保持期間も長いからです。

Next.js 側の落とし穴を1つ。`next.config.ts` の `compiler.removeConsole` を素朴に有効にすると、**本番ビルドでサーバーログが丸ごと消えます**。私はこのサイトで、この設定を `{ exclude: ["error", "warn", "info"] }` に絞る修正を入れました。障害調査でログに頼るつもりなら、`next build && next start` で実際に出力が残ることを一度確認してください。

### エラーの分類は名前で行う

`error.name` は文字列リテラル union です。ステータスコードではなく**名前で分類**してください。冒頭で触れたとおり、ドキュメントと SDK でステータスが食い違う項目があるからです。このサイトでは `lib/email-delivery.ts` に「エラー名 → `retryable` / `sender_rejected` / `fatal`」の対応表を持ち、名前が表に無いときだけ `statusCode` にフォールバックしています（対応表の中身と、その分類にした理由は [冪等性・リトライ・エラー処理ガイド](/blog/resend-idempotency-retry-error-handling-reliability-guide) に譲ります）。

route handler 側で必要なのは2点だけです。1つ目、SDK の型定義にエラーコード定数が見えても **`import` できるとは限りません**。最新版ではこの定数自体が配布物から消えており、エラー関連の公開エクスポートは `type ErrorResponse` だけです。だから自前の対応表を持つのが安全です。2つ目、`sender_rejected`（ペイロードや送信者が拒否された＝リトライしても無駄）を独立した分類として持つこと。これは私が DNS 誤配置の障害で学んだ区別そのもので、あのときは検証済みドメインが機能していなかったため、`onboarding@resend.dev` への**一度きりの送信者フォールバック**でリードを救いました。

---

## Server Actions で送る場合

Resend のドキュメントページに Server Actions のサンプルはありません（ドキュメント全文を検索しても `use server` は1件も出てきません）。しかし**公式のサンプルアプリには存在**します。`resend/resend-examples` リポジトリの `nextjs-resend-examples/typescript` にある `contact-form`（説明は "Contact form with batch send via Server Actions"）がそれで、ページ側には「これが Next.js 16 のフォームで推奨されるアプローチです」と書かれています。実装は `batch.send()` で「送信者への確認メール」と「運営者への通知メール」を**1リクエストで2通**送る形です。

```ts
'use server';

export async function submitContactForm(
  prevState: ContactFormState,
  formData: FormData,
): Promise<ContactFormState> {
  // Route Handler と同じ Zod スキーマを使う。境界が2つになっても
  // 検証の定義は1つに保つ。
  const parsed = contactFormSchema.safeParse(Object.fromEntries(formData));
  if (!parsed.success) return { success: false, error: '入力内容をご確認ください' };

  const { data, error } = await resend.batch.send([
    { from, to: [parsed.data.email], subject: '受け付けました', react: Confirmation(parsed.data) },
    { from, to: [ownerAddress], subject: '新しい問い合わせ', react: Notification(parsed.data) },
  ]);

  if (error) {
    // error.message をそのまま画面に出さない。ログに残し、利用者には固定文言を返す。
    console.error(JSON.stringify({ route: 'contact-action', phase: 'failed', name: error.name }));
    return { success: false, error: '送信に失敗しました。時間をおいてお試しください' };
  }

  // ⚠️ batch.send の戻り値は emails.send より1段深い: data.data が id の配列。
  console.info(JSON.stringify({ phase: 'sent', count: data?.data?.length ?? 0 }));
  return { success: true, error: null };
}
```

`batch.send()` は**最大100通**を1リクエストで送れ、レート制限上も**1リクエストとして数えられます**。ただし添付ファイルは非対応で、既定の検証モード（`strict`）では**1通でも不正なら全体が失敗**します。`batchValidation: 'permissive'` にすると通った分だけ送られ、失敗が `errors: { index, message }[]` で返ります。

| 観点 | Route Handler | Server Action |
|---|---|---|
| 呼び出し元 | fetch・外部システム・別サービス | 同一アプリのフォーム |
| HTTP セマンティクス | 429 や `Retry-After` をそのまま返せる | 返せない。戻り値の state で表現する |
| JavaScript 無効時 | 動かない | `form action` として動く |
| 外部からの再現 | `curl` で叩ける・冪等キーを付けやすい | フォーム経由が前提 |
| 向いている用途 | 公開API・Webhook・再試行される処理 | アプリ内のフォーム送信 |

私はこのサイトでは Route Handler を選びました。レート制限の結果を **429 と `Retry-After`** という標準的な形で返したかったこと、障害時に `curl` で本番を直接叩いて切り分けたかったことが理由です。`useActionState` を使うフォーム状態の設計は [React Hook Form と Server Actions のガイド](/blog/react-hook-form-nextjs-server-actions-useactionstate-guide) にまとめています。

---

## React Email で HTML を組む

`react:` に React コンポーネントを渡すと SDK が内部で HTML にレンダリングします。ここに v5.0.0 以降の重要な変更があります。**`@react-email/render` は optional な peer dependency** になり、SDK は必要になった時点で動的 `import` します。入っていなければ `{ data, error }` ではなく**プレーンな `Error` が throw** されます。

```ts
// SDK 配布物 (dist/index.js) より。ここが「SDK は throw しない」の例外の1つ。
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`."
  );
}
```

つまり `react:` を使う実装では **`try/catch` が必須**です。公式の Next.js サンプルに `try/catch` が付いているのはこの事情によります。さらに React Email 6.0（2026-04-16）で**パッケージ構成が変わりました**。`@react-email/components` や個別パッケージは廃止され、コンポーネントも `render` も **`react-email` 1つ**から import する形に統合されています。`renderAsync` は 5.0 で削除済みで、`render` 自体が `async` です。

私の推奨は（公式の推奨ではなく**実務判断**として）、`react:` に任せず自分で HTML 文字列にしてから渡すことです。

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

const html = await render(WelcomeEmail({ name })); // render は async
const text = toPlainText(html);                    // toPlainText は同期

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

理由は3つ。SDK の動的 import に依存しないので依存関係の解決ミスが**送信時ではなくビルド時**に露見すること。`text` を明示的に作れること（省略すると `html` から自動生成されますが内容は制御できません。空文字を渡せば自動生成を止められます）。そしてレンダリング結果をスナップショットテストにかけられること。

テンプレートの設計（Resend 側に保存する Templates、三重波括弧 `{{{VAR}}}` の変数記法、下書きと公開のライフサイクル）は [テンプレート・React Email 設計ガイド](/blog/resend-templates-variables-react-email-design-guide) で扱います。送信ペイロードの上限は次のとおりです。

| 項目 | 制約 |
|---|---|
| `to` の宛先数 | 最大 50 |
| 添付ファイル | メールあたり合計 40MB（Base64 エンコード後）。バッチ送信では非対応 |
| `tags` の `name` と `value` | ASCII 英数字・アンダースコア・ハイフンのみ、各 256 文字以内。**日本語もドットも空白も不可** |
| 冪等キー | 1〜256 文字、有効期間 24 時間 |
| 予約送信 | 最大 30 日先まで |

`tags` の文字種制限は日本語圏で確実に踏みます。`tags: [{ name: "種別", value: "問い合わせ" }]` は通りません。

---

## 失敗の見せ方（UX とアクセシビリティ）

サーバー側を固めても、画面が「押しても何も起きない」ままでは意味がありません。実際、私はこのサイトの問い合わせフォームを**108日間**壊したまま気付きませんでした。原因は前述の `null` を弾く Zod スキーマですが、**気付けなかった理由は別**で、エラーが画面のどこにも描画されない状態だったからです。以来この3点を必須にしています。

**1. エラーは `aria-live` で通知する。** 送信ボタンの近くにライブリージョンを置き、成功も失敗もそこに出します。フォーム下部にだけ描画すると、スクリーンリーダー利用者にもスクロール位置が上の利用者にも届きません。

```tsx
<p role="status" aria-live="polite" className="min-h-6 text-sm">
  {state.error ?? (state.success ? "送信しました。2営業日以内にご返信します" : "")}
</p>
```

**2. 二重送信を止める。** `isSubmitting` でボタンを `disabled` にするのは最低限で、サーバー側の冪等キーと**両方**必要です。ボタンの無効化はネットワーク再送を防ぎません。

**3. どのフィールドが原因かを必ず出す。** 検証エラーの対象が画面に描画されていないフィールドだと `handleSubmit` が黙って止まります。「送信されないのにエラーも出ない」の正体はたいていこれです。バリデーション失敗時に発火するハンドラを1つ用意し、計測イベントとして飛ばしておくと、次は108日も気付かずに済みます。

---

## ローカルと本番での検証

まずスモークテスト。429 の確認は `-i` を付けて上限回数だけ連続で叩き、`Retry-After` が秒数で返ること、`X-RateLimit-Remaining` が減ることを目で見ます。

```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"}'
```

宛先は Resend のテスト用アドレスを使います。**自作のダミーアドレスは使わないでください**（実在しないドメインへの送信はバウンス率を悪化させます）。

| アドレス | 再現する挙動 |
|---|---|
| `delivered@resend.dev` | 正常配信 |
| `bounced@resend.dev` | バウンス |
| `complained@resend.dev` | スパム報告（苦情） |
| `suppressed@resend.dev` | 抑制リストによる送信停止 |

バウンスや苦情をアプリ側で受け取る方法は [Webhook・署名検証ガイド](/blog/resend-webhooks-signature-verification-bounce-complaint-guide) を参照してください。

ドメイン検証前、`from` に `onboarding@resend.dev` を使っている間は**アカウント所有者自身のアドレスにしか送れません**。それ以外に送ると 403 で次が返ります。

```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.
```

ここを飛ばして本番ドメインへ移行しようとすると DNS レコードの配置で詰まります。私が 502 障害を出したのもここで、SPF・DKIM・MX・DMARC の4件すべてをサブドメインではなく apex に置いていました。正しい置き方は [ドメイン認証・到達率ガイド](/blog/resend-domain-authentication-spf-dkim-dmarc-deliverability-guide) にまとめています。

最後に2つの小技を。1つ目、**本番でメールを出さずに疎通を確かめる**方法。このサイトの実装ではスパム判定が **Zod 検証の後・送信の前**に入っているので、`"elapsedMs": 0` を含む正しい形のボディを投げると、検証まで到達したうえで送信されず 200 が返ります。ルーティング・検証・レート制限の疎通だけを確認できます。この順序は自分の実装でも確認してから使ってください。2つ目、SDK は `RESEND_BASE_URL` と `RESEND_USER_AGENT` の環境変数でベース URL と User-Agent を上書きできます。テストや CI でモックサーバーへ向けるのに使えますが、**配布物の中にだけ存在しドキュメントサイトには記載がない**挙動なので、バージョン更新で壊れうる前提で扱ってください。

---

## 本番投入前チェックリスト

- [ ] APIキーは `RESEND_API_KEY`（`NEXT_PUBLIC_` が**付いていない**）
- [ ] `new Resend(...)` がモジュールスコープに無い（遅延初期化）
- [ ] 入口で Zod 検証、スキーマはクライアントと**共有**
- [ ] レート制限 → 429 に `Retry-After` と `X-RateLimit-*`
- [ ] ハニーポット＋最小滞在時間、ヒット時は **200 で吸収**
- [ ] `html:` に入る全ユーザー文字列が `escapeHtml` を通っている
- [ ] `idempotencyKey` が **`send()` の第2引数**にある
- [ ] `replyTo` が相談者のアドレスになっている
- [ ] `text:` を明示的に添えている
- [ ] `maxDuration` と1回あたりの送信タイムアウトが**整合**している
- [ ] エラー分類が `error.name` ベース（`statusCode` は補助）
- [ ] クライアントに Resend の `error` オブジェクトを返していない
- [ ] ログが単一行 JSON で PII を含まず、本番ビルドで実際に出力される
- [ ] 画面側に `aria-live` のエラー通知と二重送信防止がある
- [ ] `curl` で 200 / 400 / 429 の3パターンを再現した

---

## まとめ

Next.js から Resend を叩くこと自体は10行で終わります。この記事が足したのは、その10行の**周り**にある判断です。

1. **ブラウザから呼ばない**——CORS は仕様であり、キーを守るガードレール
2. **境界は Zod ひとつ**——スキーマをクライアントと共有し、定義を2箇所に置かない
3. **`idempotencyKey` は第2引数**——第1引数に書くと無言で消え、二重送信になる
4. **遅延初期化**——モジュールスコープの `new Resend(...)` はビルドを壊す
5. **エラーは名前で分類し、名前でログに残す**——ステータスコードは公式内でも揺れている

まずは自分の route handler を開いて、`idempotencyKey` が第2引数にあるかと `new Resend(...)` の位置の2点だけ確認してください。どちらも直すのに5分、放置すると障害時に必ず効いてきます。次は全体像の [Resend 本番運用ガイド](/blog/resend-transactional-email-production-guide)、あるいは送ったメールがその後どう扱われたかを追う [Webhook・署名検証ガイド](/blog/resend-webhooks-signature-verification-bounce-complaint-guide) へ進んでください。

> この記事は [Resend 公式ドキュメント](https://resend.com/docs)（Next.js クイックスタート / API リファレンス / 冪等キー / Usage Limits、2026年8月時点）と、本サイトにインストール済みの `resend@6.4.1` の型定義・配布物に基づき、実運用での判断を加えて再構成したものです。仕様・上限値・エラーコードは更新されるため、本番採用時は各公式ページで最新値をご確認ください。
