メインコンテンツへスキップ
友田 陽大
Resend・メール配信
Resend
Next.js
TypeScript
メール配信
React Email
セキュリティ
信頼性

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

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

公開日
読了時間
25分
著者
友田 陽大
シェア

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

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

この記事は Resend 公式ドキュメントに忠実でありながら、公式サンプルが省いている本番の判断——検証・レート制限・冪等性・ログ・失敗の見せ方——を実コードで埋めます。クラスタ全体の地図は Resend 本番運用ガイド を参照してください。


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

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

古い理解(捨てる)2026年8月時点の正しい理解
resend は v3 / v4 系npm の最新は 6.18.1(2026-07-28 公開)、enginesnode >= 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() を呼ぶと、ブラウザはこう言います。

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 版が出発点です。

// 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 の messagestatusCode がブラウザに漏れる(Vercel Functions 版のサンプルはレスポンス全体を返しており露出はさらに大きい)
冪等キーがないネットワークリトライやユーザーの二度押しで同じメールが2通飛ぶ
モジュールスコープで new Resend(...)キー未設定時にコンストラクタが import 時に throw し、ビルドごと壊れる
text を添えていないプレーンテキスト派のクライアントとスパムフィルタに不利。html から自動生成はされるが内容を制御できない
ログがない失敗したことは分かるが、何回試して・どのエラー名で・どの送信者で失敗したかが残らない

本番版 Route Handler を組み立てる

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

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

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

// 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() に変えて解決しています。境界の型は「未入力」を undefinednull のどちらで表現するかまで詰めてください。

2. レート制限:429 に Retry-AfterX-RateLimit-* を添える

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 サーバーレスのレート制限ガイド に分けています。

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

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 連結です。ユーザー入力をそのまま入れれば、リンクの差し込みや属性の脱出が成立します。

// 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 します。

// ❌ モジュールスコープ: 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引数です。

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

仕様は最小限だけ押さえておきます。冪等キーは POST /emailsPOST /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つで、それぞれの分類表とリトライ設計は 冪等性・リトライ・エラー処理ガイド に譲ります。

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

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 の headersX-Entity-Ref-ID を入れるのは Gmail のスレッド化を防ぐ別機能で、重複排除はしません。決済側の同じ話題は Stripe 本番運用ガイド を参照してください。

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

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

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

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

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

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

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

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

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

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

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

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

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通送る形です。

'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 HandlerServer 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 のガイド にまとめています。


React Email で HTML を組む

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

// 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 や個別パッケージは廃止され、コンポーネントも renderreact-email 1つから import する形に統合されています。renderAsync は 5.0 で削除済みで、render 自体が async です。

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

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 設計ガイド で扱います。送信ペイロードの上限は次のとおりです。

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

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


失敗の見せ方(UX とアクセシビリティ)

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

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

<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 が減ることを目で見ます。

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・署名検証ガイド を参照してください。

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

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 に置いていました。正しい置き方は ドメイン認証・到達率ガイド にまとめています。

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


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

  • APIキーは RESEND_API_KEYNEXT_PUBLIC_付いていない
  • new Resend(...) がモジュールスコープに無い(遅延初期化)
  • 入口で Zod 検証、スキーマはクライアントと共有
  • レート制限 → 429 に Retry-AfterX-RateLimit-*
  • ハニーポット+最小滞在時間、ヒット時は 200 で吸収
  • html: に入る全ユーザー文字列が escapeHtml を通っている
  • idempotencyKeysend() の第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 本番運用ガイド、あるいは送ったメールがその後どう扱われたかを追う Webhook・署名検証ガイド へ進んでください。

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

よくある質問

Next.js のクライアントコンポーネントから直接 Resend を呼べますか?
呼べません。api.resend.com は Access-Control-Allow-Origin を返さないため、ブラウザからのリクエストは CORS のプリフライトで失敗します。公式ナレッジベースも『APIキーを露出させないため、サーバーサイドで送信してください』と明記しています。さらに Next.js の NEXT_PUBLIC_ 付き環境変数はクライアントバンドルに埋め込まれるため、NEXT_PUBLIC_RESEND_API_KEY という名前を付けた時点でキーは公開されたのと同じです。必ず Route Handler か Server Action に置いてください。
idempotencyKey はどこに書くのが正しいですか?
resend.emails.send(payload, { idempotencyKey }) の第2引数です。SDK の型定義と公式の冪等キー解説ページはどちらも第2引数の形で一致しています。一方で各クイックスタート冒頭の『AI向けプロンプト』ブロックだけが payload 内に idempotencyKey を書いており、これは型が通らないうえ、通っても SDK の変換関数がホワイトリスト外のフィールドとして捨てます。結果として警告もエラーも出ないままリトライ時に二重送信になります。
Route Handler と Server Actions はどちらを使うべきですか?
外部から叩かれうる入口、429 や Retry-After のような HTTP セマンティクスで表現したい処理、curl で再現・再送したい処理は Route Handler です。同一アプリ内のフォーム送信だけなら Server Action のほうが配線が短く、JavaScript 無効時も form action として動きます。Resend 公式のサンプルアプリはコンタクトフォームを Server Action で実装し『Next.js 16 のフォームで推奨されるアプローチ』と書いています。両方持つ場合は、検証と送信のロジックを共通モジュールに置いて重複させないでください。
React Email を使うと SDK が例外を投げることがあると聞きましたが本当ですか?
本当です。resend@6 系は @react-email/render を optional な peer dependency として動的 import します。react: オプションを使っているのに未インストールだと、SDK は { data, error } ではなくプレーンな Error を throw します。同じくコンストラクタもキー未設定時に throw します。『SDK は throw しない』という公式の記述は HTTP とネットワーク層に限った話なので、これら2箇所は try/catch で囲んでください。
開発中はどのアドレスに送ればいいですか?
Resend が用意しているテスト用アドレスを使ってください。公式が明記しているのは delivered@resend.dev、bounced@resend.dev、complained@resend.dev、suppressed@resend.dev の4つで、それぞれ配信成功・バウンス・苦情・抑制の挙動を再現します。存在しないアドレスを自作してはいけません。また from に onboarding@resend.dev を使っている間は、宛先はアカウント所有者自身のアドレスに限られ、それ以外に送ると 403 の validation_error になります。

参考文献

友田

友田 陽大

経済産業大臣賞 受賞プロダクト開発者。TypeScript + Python + AWS で、SaaS・業界DX・実用レベルの生成AI(RAG)を、要件定義からインフラ・運用まで一人で完遂します。

この記事の実装を、案件として承ります

トランザクションメール基盤(Resend)の設計・実装・本番運用を承ります

「送ったつもりで届いていない」を、運用の注意深さではなくコードとDNSの構造で潰します。送信ドメインの認証設計(SPF/DKIM/DMARC)、Next.js からの送信経路とZodによる入力検証、冪等キーと指数バックオフ・送信者フォールバックによる回復性、Webhook でのバウンス/苦情の自動処理、One-Clickでの購読解除(RFC 8058)、そして PII を載せない構造化ログでの可観測性まで。このサイト自身の問い合わせ・資料請求・7通のメール講座・決済後の納品メールを Resend で本番運用し、認証レコードの誤配置で送信が全滅した実障害を根治した経験で伴走します。

プロジェクト単位(請負)・技術顧問のどちらにも対応可能です。まずは30分の無料技術相談から。

最短ルート:カレンダーから直接予約

相談内容が固まっている方は、フォーム送信よりその場で日程を確定する方がスムーズです。下記から空き時間をお選びください。

  • 30分のオンライン無料相談
  • Google Meet / Zoom / Microsoft Teams
  • NDA 商談前締結可・無理な営業はいたしません
無料相談の空き枠を予約する

あわせて読みたい