フォームをメールに送る

フォームをメールに送る2 分で読めます

メールを送る Next.js のお問い合わせフォーム

Server Action で Next.js のフォームをメールで送る 2 つの方法を公平に比較します。Resend や Nodemailer で自分で送るか、フォームバックエンドに転送するかです。

Preet SutharIkiform 創業者

App Router では、Server Action がフォームの FormData をサーバーで受け取るので、メール API のキーを安全に扱えます。Resend や Nodemailer で自分でメールを送ることも、スパムを除外して保存し、あなたにメールで通知するフォームバックエンドに送信を転送することもできます。自分で送ればメールを完全に制御でき、転送すれば本来自分で作るはずのスパム除外、レート制限、保存の手間が省けます。

ここが違う

Next.js にはすでにサーバーがあるので、フォームバックエンドを使うよくある理由「サイトでコードを実行できない」は当てはまりません。問題は、そのコードが何をしなければならないかです。メールの送信は簡単な部分で、時間がかかるのはその周りのすべてです:

Server Action がメールを送る場合と、送信を転送する場合にすること自分で送る転送するあなたのフォームuseActionState自分のAction検証、ハニーポットを確認Resend かSMTP認証済みの自分のドメイン受信トレイだけ失われたら控えなしあなたのフォームuseActionState自分のActionシークレットキーを付けるIkiform検証、スコア付け、保存受信トレイとダッシュボードさらに WebhookFormDataAPI キーFormDataJSONServer Action がメールを送る場合と、送信を転送する場合にすること自分で送る転送するあなたのフォームuseActionState自分の Action検証、ハニーポットを確認Resend か SMTP認証済みの自分のドメイン受信トレイだけ失われたら控えなしあなたのフォームuseActionState自分の Actionシークレットキーを付けるIkiform検証、スコア付け、保存受信トレイとダッシュボードさらに WebhookFormDataAPI キーFormDataJSON
  1. 自分で送る場合: Action がフィールドを検証し、ハニーポットを確認して、API キーでメールプロバイダーを呼びます。メッセージはそのメールとしてしか存在しません。
  2. 転送する場合: Action がシークレットキーでフィールドを Ikiform に送信します。Ikiform がフォームの定義に照らして検証し、スパムをスコア付けし、送信データを保存して受信者にメールを送ります。
どちらもキーはすべてサーバーに置きます。違いは、中央の箱のうちどれだけを自分で書いて保守するかです。
仕事Server Action + Resend か NodemailerServer Action と Ikiform
バリデーションAction 内の Zodフォームのフィールド定義を Ikiform 側で適用
スパム自分のハニーポット。それ以上は自分で追加するハニーポット、スパムスコア、任意の人間確認
レート制限Redis などのストアを追加するまでなし組み込み済み。訪問者ごととフォームごと
送信ドメインSPF と DKIM のレコードで自分のドメインを認証不要。メールは notifications@ikiform.com から届く
件名とデザイン自由固定: New submission: <form name>
訪問者への確認send の呼び出しを 1 回追加なし。自分のメールツールから送る
全メッセージの控えデータベースを追加した場合だけダッシュボード、CSV エクスポート、API

スパム対策と保存を自分で引き受けてよいなら、ブランドに合わせたメールや訪問者への確認のために自作版を選んでください。一度動けばフォームのことを考えずに済ませたいなら、転送を選んでください。

両方の版

フォームはプロジェクトの問い合わせで、名前、仕事用のメール、会社、予算の範囲、説明を尋ねます。状態の型とクライアントコンポーネントは共通で、変わるのは actions.ts だけです。

app/inquiry/state.ts
export interface InquiryState {
  sent: boolean;
  errors: Partial<Record<"name" | "email" | "company" | "budget" | "message" | "form", string>>;
}

export const initialInquiry: InquiryState = { sent: false, errors: {} };
app/inquiry/inquiry-form.tsx
"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 は Allowed domains(許可ドメイン)と人間確認をスキップします。すると送信はすべてサーバーの IP から届くことになり、訪問者ごとに 1 分あたり 5 件という Standard の制限が全訪問者の合計で数えられます。サーバー経由で送信するフォームでは Relaxed か Custom のレート制限を選び、IKIFORM_SECRET_KEY はサーバー専用の環境変数に置いてください。各サーバーのサンプルは、ブラウザーの Origin ヘッダーがあればそれを引き継ぎます。サーバー自身の fetch はこのヘッダーを送らず、Ikiform は無料の localhost テスト送信をこのヘッダーで判定するからです。

3 つ目の選択肢もあります。Action を使わず、ブラウザーから直接 Ikiform に送信する方法です。シークレットキーを使わないので、訪問者ごとに Standard のレート制限と Allowed domains が適用されます。クライアントコンポーネントは Next.js のドキュメントガイドにあります。

動作を確認する

  1. 予算を空にして送信します。select の下にエラーが表示され、他の入力は失われません。
  2. 本物の問い合わせを送信します。成功の一文がフォームに置き換わります。
  3. メールを開いて Reply を押します。To の行は入力したアドレスです。
  4. ブラウザーの開発者ツールで hidden のハニーポットを埋めて送信します。成功が表示されますが、メールは届きません。
  5. ダッシュボードで 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_limited1 人の訪問者が、フォームのレート制限を超えて送信しました。Standard では訪問者ごとに 1 分あたり 5 件です。Retry-After の時間だけ待つか、1 つの共有 IP から送信するフォームでは Relaxed か Custom を選んでください。
422 validation_failed送信がフォームのフィールド定義に合っていません。error.fields に各フィールドと理由が示されます。入力欄の name をフィールド ID に合わせてください。
Action がローカルでは動くのに本番では動かないデプロイ先の環境変数にキーがありません。本番環境に追加して再デプロイしてください。

よくある質問

手順で詰まったときや、あなたの状況に合うガイドがほしいときはメールでどうぞ: hello@ikiform.com