フォームをメールに送る2 分で読めます
メールを送る Next.js のお問い合わせフォーム
Server Action で Next.js のフォームをメールで送る 2 つの方法を公平に比較します。Resend や Nodemailer で自分で送るか、フォームバックエンドに転送するかです。
App Router では、Server Action がフォームの FormData をサーバーで受け取るので、メール API のキーを安全に扱えます。Resend や Nodemailer で自分でメールを送ることも、スパムを除外して保存し、あなたにメールで通知するフォームバックエンドに送信を転送することもできます。自分で送ればメールを完全に制御でき、転送すれば本来自分で作るはずのスパム除外、レート制限、保存の手間が省けます。
ここが違う
Next.js にはすでにサーバーがあるので、フォームバックエンドを使うよくある理由「サイトでコードを実行できない」は当てはまりません。問題は、そのコードが何をしなければならないかです。メールの送信は簡単な部分で、時間がかかるのはその周りのすべてです:
- 自分で送る場合: Action がフィールドを検証し、ハニーポットを確認して、API キーでメールプロバイダーを呼びます。メッセージはそのメールとしてしか存在しません。
- 転送する場合: Action がシークレットキーでフィールドを Ikiform に送信します。Ikiform がフォームの定義に照らして検証し、スパムをスコア付けし、送信データを保存して受信者にメールを送ります。
| 仕事 | Server Action + Resend か Nodemailer | Server Action と Ikiform |
|---|---|---|
| バリデーション | Action 内の Zod | フォームのフィールド定義を Ikiform 側で適用 |
| スパム | 自分のハニーポット。それ以上は自分で追加する | ハニーポット、スパムスコア、任意の人間確認 |
| レート制限 | Redis などのストアを追加するまでなし | 組み込み済み。訪問者ごととフォームごと |
| 送信ドメイン | SPF と DKIM のレコードで自分のドメインを認証 | 不要。メールは notifications@ikiform.com から届く |
| 件名とデザイン | 自由 | 固定: New submission: <form name> |
| 訪問者への確認 | send の呼び出しを 1 回追加 | なし。自分のメールツールから送る |
| 全メッセージの控え | データベースを追加した場合だけ | ダッシュボード、CSV エクスポート、API |
スパム対策と保存を自分で引き受けてよいなら、ブランドに合わせたメールや訪問者への確認のために自作版を選んでください。一度動けばフォームのことを考えずに済ませたいなら、転送を選んでください。
両方の版
フォームはプロジェクトの問い合わせで、名前、仕事用のメール、会社、予算の範囲、説明を尋ねます。状態の型とクライアントコンポーネントは共通で、変わるのは actions.ts だけです。
export interface InquiryState {
sent: boolean;
errors: Partial<Record<"name" | "email" | "company" | "budget" | "message" | "form", string>>;
}
export const initialInquiry: InquiryState = { sent: false, errors: {} };"use client";
import { useActionState } from "react";
import { useFormStatus } from "react-dom";
import { sendInquiry } from "./actions";
import { initialInquiry } from "./state";
function SubmitButton() {
const { pending } = useFormStatus();
return <button disabled={pending}>{pending ? "Sending" : "Send inquiry"}</button>;
}
export function InquiryForm() {
const [state, action] = useActionState(sendInquiry, initialInquiry);
if (state.sent) return <p role="status">Thanks. We reply to every inquiry within two working days.</p>;
return (
<form action={action}>
<label htmlFor="name">Name</label>
<input id="name" name="name" autoComplete="name" required />
{state.errors.name ? <p role="alert">{state.errors.name}</p> : null}
<label htmlFor="email">Work email</label>
<input id="email" name="email" type="email" autoComplete="email" required />
{state.errors.email ? <p role="alert">{state.errors.email}</p> : null}
<label htmlFor="company">Company</label>
<input id="company" name="company" autoComplete="organization" />
<label htmlFor="budget">Budget</label>
<select id="budget" name="budget" required defaultValue="">
<option value="" disabled>Choose a range</option>
<option value="under-5k">Under $5k</option>
<option value="5k-20k">$5k to $20k</option>
<option value="over-20k">Over $20k</option>
</select>
{state.errors.budget ? <p role="alert">{state.errors.budget}</p> : null}
<label htmlFor="message">About the project</label>
<textarea id="message" name="message" rows={6} required minLength={20} />
{state.errors.message ? <p role="alert">{state.errors.message}</p> : null}
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{state.errors.form ? <p role="alert">{state.errors.form}</p> : null}
<SubmitButton />
</form>
);
}"use server";
import { Resend } from "resend";
import { z } from "zod";
import type { InquiryState } from "./state";
const resend = new Resend(process.env.RESEND_API_KEY);
const schema = z.object({
name: z.string().trim().min(1, "Tell us your name.").max(120),
email: z.email("Enter an address we can reply to."),
company: z.string().trim().max(120).optional(),
budget: z.enum(["under-5k", "5k-20k", "over-20k"], "Pick a budget range."),
message: z.string().trim().min(20, "A few sentences help us reply properly.").max(5000),
});
export async function sendInquiry(_previous: InquiryState, formData: FormData): Promise<InquiryState> {
if (formData.get("_gotcha")) return { sent: true, errors: {} };
const parsed = schema.safeParse(Object.fromEntries(formData));
if (!parsed.success) {
const fields = z.flattenError(parsed.error).fieldErrors;
return {
sent: false,
errors: Object.fromEntries(Object.entries(fields).map(([key, messages]) => [key, messages?.[0]])),
};
}
const inquiry = parsed.data;
const { error } = await resend.emails.send({
from: "Website <inquiries@yourdomain.com>",
to: ["you@yourdomain.com"],
replyTo: inquiry.email,
subject: `Project inquiry from ${inquiry.name}`,
text: [
`Name: ${inquiry.name}`,
`Email: ${inquiry.email}`,
`Company: ${inquiry.company ?? "-"}`,
`Budget: ${inquiry.budget}`,
"",
inquiry.message,
].join("\n"),
});
if (error) return { sent: false, errors: { form: "Couldn't send your inquiry. Try again." } };
return { sent: true, errors: {} };
}"use server"のファイルからは async 関数しかエクスポートできないので、状態の型は別のファイルに置きます。useFormStatusは自分が含まれるフォームの送信中状態を読むので、ボタンは独立したコンポーネントにします。- Resend 版では、ハニーポットが埋まっていれば送信せずに成功を返すので、ボットは何も学べません。Ikiform も、送信をスパムとして保存するときに同じことをします。
- Resend 版の
replyToと、Ikiform 版のemailという名前のフィールドは、どちらも Reply を訪問者宛にします。 - Nodemailer も Resend と同じように使えます。SMTP の情報でトランスポートを作り、
from、to、replyTo、subject、textを指定してsendMailを呼びます。 - Ikiform 版では、フォームの Fields タブで 5 つのフィールドを定義し、
budgetをselectにします。不正な値にはフィールドごとのメッセージ付きで422 validation_failedが返ります。
IKIFORM_SECRET_KEY はサーバー専用の環境変数に置いてください。各サーバーのサンプルは、ブラウザーの Origin ヘッダーがあればそれを引き継ぎます。サーバー自身の fetch はこのヘッダーを送らず、Ikiform は無料の localhost テスト送信をこのヘッダーで判定するからです。3 つ目の選択肢もあります。Action を使わず、ブラウザーから直接 Ikiform に送信する方法です。シークレットキーを使わないので、訪問者ごとに Standard のレート制限と Allowed domains が適用されます。クライアントコンポーネントは Next.js のドキュメントガイドにあります。
動作を確認する
- 予算を空にして送信します。select の下にエラーが表示され、他の入力は失われません。
- 本物の問い合わせを送信します。成功の一文がフォームに置き換わります。
- メールを開いて Reply を押します。To の行は入力したアドレスです。
- ブラウザーの開発者ツールで hidden のハニーポットを埋めて送信します。成功が表示されますが、メールは届きません。
- ダッシュボードで Submissions を開きます。送ったすべてのフィールドを含む新しい送信データがあります。この手順は Ikiform 版だけが対象です。
ハマりどころ
- HTML メールでのエスケープ。 Resend 版は
textを送ります。htmlに切り替えるなら、先にすべての値をエスケープしてください。そうしないと、訪問者がマークアップやリンクを受信トレイに送り込めます。 - ヘッダーインジェクション。 フォームの値をそのまま
fromやtoに入れてはいけません。Resend でreplyToが安全なのは、Zod がメールアドレスであることを確認済みだからです。 - 環境変数。
RESEND_API_KEYとIKIFORM_SECRET_KEYはNEXT_PUBLIC_で始めてはいけません。始めると Next.js がブラウザーに送ってしまいます。 - 重複送信。 ダブルクリックで Action が 2 回実行されることがあります。送信中の状態でボタンは無効になりますが、遅いネットワークでは競合が起こりえます。Resend の
idempotencyKeyオプションや Ikiform のIdempotency-Keyヘッダーを使えば、繰り返しは何もしません。 - Server Action は公開エンドポイントです。 誰でも直接呼び出せるので、バリデーションとスパムチェックはフォームだけでなく Action の中で実行しなければなりません。
うまくいかないとき
| 問題 | 対処法 |
|---|---|
| Resend がドメインが認証されていないと応答する | from のドメインに Resend の DNS レコードを追加するか、開発中は Resend のテスト用アドレスから送ってください。 |
429 rate_limited | 1 人の訪問者が、フォームのレート制限を超えて送信しました。Standard では訪問者ごとに 1 分あたり 5 件です。Retry-After の時間だけ待つか、1 つの共有 IP から送信するフォームでは Relaxed か Custom を選んでください。 |
422 validation_failed | 送信がフォームのフィールド定義に合っていません。error.fields に各フィールドと理由が示されます。入力欄の name をフィールド ID に合わせてください。 |
| Action がローカルでは動くのに本番では動かない | デプロイ先の環境変数にキーがありません。本番環境に追加して再デプロイしてください。 |
よくある質問
はい。Server Action がデフォルトで使う Node.js ランタイムなら動きます。SMTP 接続を開くので Edge ランタイムでは動かず、送信 SMTP を制限しているホストもあります。Resend のような HTTP のメール API なら、どちらの問題もありません。
サーバーで実行される Server Action でフォームを処理します。そこから Resend や Nodemailer などのメール API を呼ぶか、メールで通知してくれるフォームバックエンドにフィールドを転送します。
はい。名前が NEXT_PUBLIC_ で始まらない限り、API キーはサーバーにとどまります。Server Action は直接呼び出せるので、すべてのフィールドを Action の中で検証してください。
メールを送るだけなら不要です。メールが失われても残るメッセージの記録が欲しいなら必要です。フォームバックエンドなら、その記録を代わりに残してくれます。
いいえ。Ikiform がメールを送るのはあなたとチームだけです。確認メールは、Server Action の中か Webhook から、自分のメールプロバイダーで送ってください。
手順で詰まったときや、あなたの状況に合うガイドがほしいときはメールでどうぞ: hello@ikiform.com