「動くだけの送信フォーム」と「本番で使える送信フォーム」の差は、成功したときの挙動ではなく、失敗したときに何が起きるかで決まります。プロバイダが 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 公開)、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() を呼ぶと、ブラウザはこう言います。
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 の message と statusCode がブラウザに漏れる(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() に変えて解決しています。境界の型は「未入力」を undefined と null のどちらで表現するかまで詰めてください。
2. レート制限:429 に Retry-After と X-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({
"&": "&",
"<": "<",
">": ">",
'"': """,
"'": "'",
});
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向けプロンプト」ブロックだけが 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つで、それぞれの分類表とリトライ設計は 冪等性・リトライ・エラー処理ガイド に譲ります。
クライアントから 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 の headers に X-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.ts の compiler.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 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 のガイド にまとめています。
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 や個別パッケージは廃止され、コンポーネントも render も react-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 エンコード後)。バッチ送信では非対応 |
tags の name と value | ASCII 英数字・アンダースコア・ハイフンのみ、各 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・署名検証ガイド を参照してください。
ドメイン検証前、from に onboarding@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_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行の周りにある判断です。
- ブラウザから呼ばない——CORS は仕様であり、キーを守るガードレール
- 境界は Zod ひとつ——スキーマをクライアントと共有し、定義を2箇所に置かない
idempotencyKeyは第2引数——第1引数に書くと無言で消え、二重送信になる- 遅延初期化——モジュールスコープの
new Resend(...)はビルドを壊す - エラーは名前で分類し、名前でログに残す——ステータスコードは公式内でも揺れている
まずは自分の route handler を開いて、idempotencyKey が第2引数にあるかと new Resend(...) の位置の2点だけ確認してください。どちらも直すのに5分、放置すると障害時に必ず効いてきます。次は全体像の Resend 本番運用ガイド、あるいは送ったメールがその後どう扱われたかを追う Webhook・署名検証ガイド へ進んでください。
この記事は Resend 公式ドキュメント(Next.js クイックスタート / API リファレンス / 冪等キー / Usage Limits、2026年8月時点)と、本サイトにインストール済みの
resend@6.4.1の型定義・配布物に基づき、実運用での判断を加えて再構成したものです。仕様・上限値・エラーコードは更新されるため、本番採用時は各公式ページで最新値をご確認ください。