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

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

公開日 2026-10-25 · https://ikiform.com/ja/guides/send-form-to-email/nextjs

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

## ここが違う

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

図: Server Action がメールを送る場合と、送信を転送する場合にすること

1. 自分で送る場合: Action がフィールドを検証し、ハニーポットを確認して、API キーでメールプロバイダーを呼びます。メッセージはそのメールとしてしか存在しません。
2. 転送する場合: 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` だけです。

`app/inquiry/state.ts`:

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

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

**Resend** (`app/inquiry/actions.ts`)

```ts
"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: {} };
}
```

**Ikiform** (`app/inquiry/actions.ts`)

```ts
"use server";

import { headers } from "next/headers";
import type { InquiryState } from "./state";

export async function sendInquiry(_previous: InquiryState, formData: FormData): Promise<InquiryState> {
  const origin = (await headers()).get("origin");
  const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
    method: "POST",
    headers: {
      Accept: "application/json",
      "Content-Type": "application/json",
      Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
      ...(origin ? { Origin: origin } : {}),
    },
    body: JSON.stringify({
      name: formData.get("name"),
      email: formData.get("email"),
      company: formData.get("company"),
      budget: formData.get("budget"),
      message: formData.get("message"),
      _gotcha: formData.get("_gotcha") ?? "",
    }),
  });
  const result = await response.json().catch(() => null);
  if (result?.ok) return { sent: true, errors: {} };

  const errors: InquiryState["errors"] = {};
  for (const [field, messages] of Object.entries<string[]>(result?.error?.fields ?? {})) {
    errors[field as keyof InquiryState["errors"]] = messages[0];
  }
  if (!Object.keys(errors).length) errors.form = result?.error?.message ?? "Couldn't send your inquiry. Try again.";
  return { sent: false, 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 の[レート制限](https://ikiform.com/ja/docs/rate-limits.md)を選び、`IKIFORM_SECRET_KEY` はサーバー専用の環境変数に置いてください。各サーバーのサンプルは、ブラウザーの `Origin` ヘッダーがあればそれを引き継ぎます。サーバー自身の `fetch` はこのヘッダーを送らず、Ikiform は無料の `localhost` テスト送信をこのヘッダーで判定するからです。

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

## 動作を確認する

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_limited` | 1 人の訪問者が、フォームのレート制限を超えて送信しました。Standard では訪問者ごとに 1 分あたり 5 件です。`Retry-After` の時間だけ待つか、1 つの共有 IP から送信するフォームでは Relaxed か Custom を選んでください。 |
| `422 validation_failed` | 送信がフォームのフィールド定義に合っていません。`error.fields` に各フィールドと理由が示されます。入力欄の `name` をフィールド ID に合わせてください。 |
| Action がローカルでは動くのに本番では動かない | デプロイ先の環境変数にキーがありません。本番環境に追加して再デプロイしてください。 |

## よくある質問

### Nodemailer は Server Action で動きますか？

はい。Server Action がデフォルトで使う Node.js ランタイムなら動きます。SMTP 接続を開くので Edge ランタイムでは動かず、送信 SMTP を制限しているホストもあります。Resend のような HTTP のメール API なら、どちらの問題もありません。

### Next.js のフォームからメールを送るには？

サーバーで実行される Server Action でフォームを処理します。そこから Resend や Nodemailer などのメール API を呼ぶか、メールで通知してくれるフォームバックエンドにフィールドを転送します。

### Server Action から Resend を呼んでも安全ですか？

はい。名前が NEXT_PUBLIC_ で始まらない限り、API キーはサーバーにとどまります。Server Action は直接呼び出せるので、すべてのフィールドを Action の中で検証してください。

### Next.js のお問い合わせフォームにデータベースは必要ですか？

メールを送るだけなら不要です。メールが失われても残るメッセージの記録が欲しいなら必要です。フォームバックエンドなら、その記録を代わりに残してくれます。

### Ikiform から訪問者に確認メールを送れますか？

いいえ。Ikiform がメールを送るのはあなたとチームだけです。確認メールは、Server Action の中か Webhook から、自分のメールプロバイダーで送ってください。

## その他のガイド

- [フォームをメールに送る](https://ikiform.com/ja/guides/send-form-to-email.md): メールサーバーなしで、すべての送信データを受信トレイに届けます。mailto: と PHP の mail() がうまくいかない理由と、スパムを除外して返信もできる構成を解説します。
- [EmailJS の代替](https://ikiform.com/ja/guides/send-form-to-email/emailjs-alternative.md): お問い合わせフォームを EmailJS から移行。ブラウザー側のメールキーのリスク、コードの変更点、自動返信など失うものを解説します。
- [複数の受信者](https://ikiform.com/ja/guides/send-form-to-email/multiple-recipients.md): 1 つのフォームをチーム全員にメールで送ったり、トピックごとに適切な担当者に振り分けたり。ただし、誰に送るかをフォーム自身には決めさせません。
- [受信トレイから返信](https://ikiform.com/ja/guides/send-form-to-email/reply-to.md): フォームの通知メールで Reply を押すと、フォームに入力した人宛てになるようにします。使えるフィールド名、_replyto を使う場面、返信が迷子になる理由を説明します。
- [PHP なしで](https://ikiform.com/ja/guides/send-form-to-email/without-php.md): mail() を呼ぶ contact.php スクリプトを、ホストされたエンドポイントに送信するフォームに置き換えます。フィールド名、サンクスページ、返信はそのままです。
- [Gmail へ](https://ikiform.com/ja/guides/send-form-to-email/gmail.md): mailto: リンクもアプリパスワードも Apps Script も使わずに、フォームの送信データを Gmail で受け取り、ラベルを付け、迷惑メールを避け、Gmail から返信します。
