ゆうふの研究室
Web制作更新日 2026.09.24

Next.jsとResendでお問い合わせフォームを実装した記録

ゆうふの研究室にお問い合わせフォームを追加し、Next.jsのAPI RouteからResendでメールを送信できるようにした手順と、Cloudflare Workersで環境変数が読み込まれなかった問題の解決方法をまとめます。

「ゆうふの研究室」に、お問い合わせフォームを追加しました。

記事の内容やプロジェクトについて連絡できる窓口を用意し、入力された内容を自分のメールアドレスで受け取れる仕組みです。

今回は、Next.jsのAPI Routeでフォームの内容を受け取り、メール送信サービスのResendを利用してメールを送信しました。サイトはCloudflare Workersで公開しているため、環境変数の登録では少しつまずきました。

この記事では、実装した構成と送信処理、発生した問題、その解決方法を記録します。

今回作ったもの

お問い合わせページには、次の入力項目を用意しました。

  • お問い合わせ種別
  • メールアドレス
  • 件名
  • お問い合わせ内容

お問い合わせ種別は、内容を確認しやすいように次の4種類に分けています。

  • 記事について
  • プロジェクトについて
  • 不具合・誤字の報告
  • その他

送信ボタンを押すと、フォームの内容がNext.jsのAPIへ送られます。API側で入力値を確認したあと、Resendを通じて管理者用のメールアドレスへ届ける構成にしました。

全体の構成

今回のお問い合わせフォームは、次の流れで動作します。

  1. 利用者がお問い合わせフォームを入力する
  2. ブラウザから /api/contact へJSON形式で送信する
  3. Next.jsのAPI Routeで入力内容を検証する
  4. API RouteからResendのAPIを呼び出す
  5. 指定したメールアドレスでお問い合わせを受信する

ResendのAPIキーはブラウザへ渡さず、サーバー側だけで使用します。秘密にする必要がある値をフロントエンドへ含めないことが重要です。

Resendを選んだ理由

お問い合わせ内容をメールで受け取るため、今回はResendを使用しました。

Resendにはメール送信用のAPIがあり、Next.jsから比較的少ないコードで利用できます。また、独自ドメインを送信元として設定できるため、サイトに合わせたメールアドレスを使用できます。

「ゆうふの研究室」では、送信元として次のようなアドレスを設定しました。

contact@mail.yufu-lab.com

実際に使用するには、Resend側で送信用ドメインを登録し、案内されたDNSレコードをCloudflareへ追加して、ドメイン認証を完了させる必要があります。

使用する環境変数

送信処理では、次の3つの環境変数を使用します。

RESEND_API_KEY
CONTACT_TO_EMAIL
CONTACT_FROM_EMAIL

それぞれの役割は次のとおりです。

環境変数用途
RESEND_API_KEYResendのAPIを利用するための秘密鍵
CONTACT_TO_EMAILお問い合わせを受信するメールアドレス
CONTACT_FROM_EMAILResendで認証した送信元メールアドレス

APIキーや受信先のアドレスは、ソースコードへ直接書かないようにします。GitHubへ誤って公開しないためにも、環境変数として管理する方が安全です。

ローカル環境で動作確認する場合は、プロジェクト直下の .env.local に設定できます。

RESEND_API_KEY=re_xxxxxxxxxx
CONTACT_TO_EMAIL=your-address@example.com
CONTACT_FROM_EMAIL=contact@mail.example.com

.env.local はGitの管理対象に含めないようにします。

API Routeを作成する

Next.jsのApp Routerでは、app/api/contact/route.ts を作成すると、/api/contact で利用できるAPI Routeになります。

今回の送信処理は、次のような構成にしました。

type ContactRequest = {
  contactType?: unknown;
  email?: unknown;
  subject?: unknown;
  message?: unknown;
  website?: unknown;
};

const contactTypeLabels: Record<string, string> = {
  article: "記事について",
  project: "プロジェクトについて",
  bug: "不具合・誤字の報告",
  other: "その他",
};

function json(data: unknown, status = 200) {
  return Response.json(data, { status });
}

export async function POST(request: Request) {
  try {
    const resendApiKey = process.env.RESEND_API_KEY;
    const contactToEmail = process.env.CONTACT_TO_EMAIL;
    const contactFromEmail = process.env.CONTACT_FROM_EMAIL;

    if (!resendApiKey || !contactToEmail || !contactFromEmail) {
      console.error("Contact environment variables are missing");
      return json({ error: "メール送信設定が完了していません。" }, 500);
    }

    const contentType = request.headers.get("content-type") ?? "";

    if (!contentType.includes("application/json")) {
      return json({ error: "送信形式が正しくありません。" }, 415);
    }

    const body = (await request.json()) as ContactRequest;

    // 利用者には表示しない、ボット対策用の入力欄
    if (typeof body.website === "string" && body.website !== "") {
      return json({
        success: true,
        message: "お問い合わせを送信しました。",
      });
    }

    const contactType =
      typeof body.contactType === "string" ? body.contactType.trim() : "";
    const email = typeof body.email === "string" ? body.email.trim() : "";
    const subject =
      typeof body.subject === "string" ? body.subject.trim() : "";
    const message =
      typeof body.message === "string" ? body.message.trim() : "";

    if (!contactType || !email || !subject || !message) {
      return json({ error: "必須項目をすべて入力してください。" }, 400);
    }

    if (!contactTypeLabels[contactType]) {
      return json({ error: "お問い合わせ種別をご確認ください。" }, 400);
    }

    if (email.length > 254 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
      return json({ error: "メールアドレスをご確認ください。" }, 400);
    }

    if (subject.length > 200) {
      return json({ error: "件名が長すぎます。" }, 400);
    }

    if (message.length > 5000) {
      return json(
        { error: "お問い合わせ内容は5000文字以内で入力してください。" },
        400,
      );
    }

    // メール件名へ改行を挿入されないようにする
    const safeSubject = subject.replace(/[\r\n]/g, " ");
    const contactTypeLabel = contactTypeLabels[contactType];

    const resendResponse = await fetch("https://api.resend.com/emails", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${resendApiKey}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        from: `ゆうふの研究室 <${contactFromEmail}>`,
        to: [contactToEmail],
        reply_to: email,
        subject: `【ゆうふの研究室】${safeSubject}`,
        text: [
          "ゆうふの研究室からお問い合わせが届きました。",
          "",
          `お問い合わせ種別:${contactTypeLabel}`,
          `メールアドレス:${email}`,
          `件名:${safeSubject}`,
          "",
          "お問い合わせ内容:",
          message,
        ].join("\n"),
      }),
    });

    if (!resendResponse.ok) {
      console.error(
        "Resend error:",
        resendResponse.status,
        await resendResponse.text(),
      );

      return json(
        { error: "メールを送信できませんでした。時間をおいてお試しください。" },
        500,
      );
    }

    return json({
      success: true,
      message: "お問い合わせを送信しました。",
    });
  } catch (error) {
    console.error("Contact API error:", error);
    return json({ error: "送信中にエラーが発生しました。" }, 500);
  }
}

export function GET() {
  return json({ error: "許可されていない操作です。" }, 405);
}

フォームからAPIへ送信する

フロントエンドでは、入力内容をJSONに変換して /api/contact へ送信します。

送信部分を簡略化すると、次のようになります。

const response = await fetch("/api/contact", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    contactType,
    email,
    subject,
    message,
    website,
  }),
});

const contentType = response.headers.get("content-type") ?? "";

if (!contentType.includes("application/json")) {
  throw new Error("サーバーから正しい応答を受け取れませんでした。");
}

const result = (await response.json()) as {
  success?: boolean;
  message?: string;
  error?: string;
};

if (!response.ok) {
  throw new Error(result.error ?? "お問い合わせを送信できませんでした。");
}

送信中はボタンを無効にし、連続して送信されないようにしています。また、成功時と失敗時には、利用者が結果を確認できるメッセージを表示します。

Unexpected token '<' エラーが発生した

実装中、フォームを送信すると次のエラーが表示されました。

Unexpected token '<', "<!DOCTYPE "... is not valid JSON

このエラーは、フロントエンドがJSONを期待しているのに、サーバーからHTMLが返されたときに発生します。

たとえば、API Routeが正しくデプロイされておらず404ページが返された場合、レスポンスの先頭は <!DOCTYPE html> になります。その内容を response.json() で読み込もうとすると、JSONではないためエラーになります。

原因を確認するときは、次の点を調べます。

  • /api/contact が公開環境に存在するか
  • APIのURLに間違いがないか
  • Cloudflareへのデプロイが成功しているか
  • レスポンスのステータスと content-type は何か
  • Workerのログにエラーが出ていないか

今回は、JSONを読み込む前に content-type を確認する処理も加えました。これにより、HTMLが返ってきた場合でも、利用者へ分かりやすいエラーを表示できます。

環境変数がWorkerに反映されなかった

API Routeが動作するようになったあと、次は次のメッセージが表示されました。

メール送信設定が完了していません。

Cloudflareのログを確認すると、3つの環境変数がすべて読み込まれていませんでした。

hasResendApiKey: false
hasContactToEmail: false
hasContactFromEmail: false

Cloudflareの設定画面には値を登録していましたが、実際に動いているWorkerからは参照できていない状態でした。

そこで、Wranglerから対象のWorkerを明示して、Secretを登録しました。

npx wrangler secret put RESEND_API_KEY --name yufu-lab-blog
npx wrangler secret put CONTACT_TO_EMAIL --name yufu-lab-blog
npx wrangler secret put CONTACT_FROM_EMAIL --name yufu-lab-blog

最初は --name を付けずに実行したため、次のエラーになりました。

Required Worker name missing.
Please specify the Worker name in your Wrangler configuration file,
or pass it as an argument with --name <worker-name>

今回のWorker名は yufu-lab-blog なので、各コマンドに --name yufu-lab-blog を付けることで登録できました。

なお、コマンドを実行すると値の入力を求められます。APIキーなどの秘密情報は、コマンドそのものへ直接書かない方が安全です。

デプロイ時に環境変数を残す

Wranglerで再デプロイするときは、Cloudflare側に登録した環境変数を意図せず削除しないように注意が必要です。

今回のデプロイコマンドでは、--keep-vars を付けています。

npx wrangler deploy --keep-vars

環境変数の管理方法は、wrangler.jsonc などの設定ファイルへまとめる方法もあります。ただし、APIキーのような秘密情報は設定ファイルへ直接書かず、Secretとして登録します。

ボットによる送信への対策

公開されたお問い合わせフォームには、自動送信による迷惑メッセージが届く可能性があります。

今回は簡単な対策として、通常の利用者には見えない website という入力欄を用意しました。人間は入力しませんが、フォーム内のすべての項目を自動入力するボットは、この欄にも値を入れる場合があります。

API側では website に値が入っていた場合、実際のメール送信を行わずに処理を終了します。この仕組みは「ハニーポット」と呼ばれます。

ただし、ハニーポットだけですべての迷惑送信を防げるわけではありません。今後送信が増えた場合は、次の対策も検討します。

  • IPアドレスごとの送信回数制限
  • Cloudflare Turnstileの導入
  • 同一内容の連続送信を防ぐ仕組み
  • 本文やURL数に応じた迷惑メッセージ判定

セキュリティ面で注意したこと

お問い合わせフォームでは、外部から送られた値をそのまま信用しないことが大切です。

今回はAPI側で、次の確認を行っています。

  • 必須項目が入力されているか
  • 定義済みのお問い合わせ種別か
  • メールアドレスの形式が不自然ではないか
  • 件名が200文字以内か
  • 本文が5000文字以内か
  • リクエストがJSON形式か
  • 件名から改行コードを取り除いているか

ブラウザ側にも入力チェックを用意できますが、それだけでは十分ではありません。APIはブラウザを通さず直接呼び出すこともできるため、サーバー側でも必ず検証します。

また、APIのエラーメッセージにはResendの詳しい応答や環境変数の値を含めず、詳細はWorkerのログで確認するようにしました。

プライバシーポリシーへの記載

お問い合わせフォームでは、返信のためにメールアドレスを取得します。

そのため、プライバシーポリシーには、少なくとも次の内容を記載します。

  • お問い合わせ時にメールアドレスなどを取得すること
  • 取得した情報を問い合わせへの対応や本人確認に利用すること
  • 法令に基づく場合などを除き、本人の同意なく第三者へ提供しないこと
  • 不要になった情報を適切に管理または削除すること

フォームの近くからプライバシーポリシーへ移動できるようにしておくと、利用者も情報の扱いを確認しやすくなります。

動作確認で確認した項目

公開前には、次の項目を確認しました。

  • 必須項目を空欄にすると送信されない
  • 不正なメールアドレスを入力するとエラーになる
  • 正常に送信すると完了メッセージが表示される
  • 指定した受信先へメールが届く
  • 受信したメールへ返信すると入力者のアドレスが宛先になる
  • APIキーがブラウザへ表示されていない
  • Workerのログに個人情報や秘密情報を必要以上に出していない
  • スマートフォンでもフォームを操作できる

実際に自分のメールアドレスから何度か送信し、受信と返信まで確認しました。

今後改善したいこと

現時点では、基本的なお問い合わせを受け取れるようになりました。今後は利用状況を見ながら、次の改善も行いたいと考えています。

  • Cloudflare Turnstileによる迷惑送信対策
  • 送信回数の制限
  • 入力内容の確認画面
  • 送信者への自動返信
  • アクセシビリティの改善
  • エラー表示の分かりやすさの改善

特に、自動返信はメールアドレスの入力間違いにも関係するため、導入する場合は悪用されないための対策と合わせて検討します。

まとめ

今回は、Next.jsのAPI RouteとResendを使って、「ゆうふの研究室」にお問い合わせフォームを実装しました。

フォーム自体の実装だけでなく、入力値の検証、ボット対策、秘密情報の管理、Cloudflare Workersへの環境変数の登録など、公開環境で動かすために考えることが多くありました。

特に、Cloudflareの設定画面へ値を登録していても、実行中のWorkerから読み込めているとは限りません。問題が起きたときは、画面上の設定だけでなく、Workerのログで実際の状態を確認することが大切だと分かりました。

お問い合わせフォームが完成したことで、記事や制作物について連絡を受け取るための窓口を用意できました。今後も、迷惑送信への対策や使いやすさを少しずつ改善していきます。