テクノロジー

【実務レベル】共有サーバーのNext.jsに問い合わせフォームを足す|API Routesが使えないときのPHP受信

【実務レベル】共有サーバーのNext.jsに問い合わせフォームを足す|API Routesが使えないときのPHP受信

共有サーバーへ Next.js を書き出したあと、問い合わせだけをどうするか問題が残ります。app/api に送るフォームは、ローカルでは動いて、本番のビルドでは動きません。静的書き出しには、そのAPIを実行するNodeが無いためです。

結論です。共有サーバー上の Next.js では、フォームの受け口を Next の外に置きます。 同じ公開ディレクトリの PHP が、宛先を固定してメールを出す、が現場で多い形です。外部のフォームサービスでも足ります。どちらも、完了画面が出たことと、担当者の受信箱に届いたことは別です。

この記事では、フリーランスのWebディレクター兼エンジニアとして、静的エクスポート済みのサイトにフォームを足す手順を整理します。到達率が改善した、といった確認できない数字は出しません。書き出しそのもの(output: "export"、末尾スラッシュ、画像)は、共有サーバーへ公開する手順の記事を前提にします。ここでは受け口だけを書きます。

なぜ Next の API では受け取れないか

output: "export" の成果物は、HTML、CSS、JSです。app/api も pages/api も、リクエストのたびにNodeが動く機能です。ビルド時に落とされるか、上げてもURLが404になります。

共有サーバーは PHP の実行が最初からあります。問い合わせ1本のためにプランを変える必要はありません。Vercel に移して Route Handler を使う選択もあります。この記事は、サーバー契約を残す側の手順です。

置き場所は public の中

PHP をサーバーにだけ置くと、次のアップロードで消えます。rsync --delete や、out の中身で public_html を上書きする運用です。

Next.js は、public/ のファイルを書き出し先へコピーします。public/contact.php に置くと、out/contact.php に含まれ、HTMLと一緒に上がります。フォームの送り先は、同じドメインの /contact.php です。別ドメインにすると、CORSの設定がもう一段要ります。まずは同じホストに置きます。

画面側:静的ページから POST する

入力欄の名前と、PHP側の受け取り名を揃えます。送り先のメールアドレスは、ブラウザのJSに書きません。宛先を隠すためではなく、訪れた人が宛先を書き換えられないようにするためです。

"use client";

import { useState } from "react";

export default function ContactForm() {
  const [status, setStatus] = useState<"idle" | "sending" | "ok" | "ng">("idle");

  async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault();
    setStatus("sending");
    const response = await fetch("/contact.php", {
      method: "POST",
      body: new FormData(event.currentTarget),
    });
    setStatus(response.ok ? "ok" : "ng");
  }

  return (
    <form onSubmit={onSubmit}>
      <label>
        名前
        <input name="name" required autoComplete="name" />
      </label>
      <label>
        メールアドレス
        <input name="email" type="email" required autoComplete="email" />
      </label>
      <label>
        内容
        <textarea name="message" required />
      </label>
      <input name="company" tabIndex={-1} autoComplete="off" className="hp" />
      <button type="submit" disabled={status === "sending"}>
        送信
      </button>
      {status === "ok" ? <p>受け付けました。届いたかは受信箱で確認します。</p> : null}
      {status === "ng" ? <p>送信できませんでした。時間をおいて再度お試しください。</p> : null}
    </form>
  );
}

company は人が埋めない欄です。画面の外に置きます。自動送信の多くは、見えている欄を全部埋めます。空でないとき、PHPは何も送らず成功だけ返します。これで広告がゼロになる、とは言いません。明らかに機械の投稿を、担当の受信箱へ入れないための一段です。

.hp {
  position: absolute;
  left: -9999px;
}

サーバー側:宛先を固定した PHP

宛先を入力値にしてはいけません。第三者のアドレスへ送れるフォームは、迷惑メールの踏み台になります。宛先はファイルの中に固定します。訪問者のアドレスは Reply-To にだけ使い、改行を除きます。改行が残ると、ヘッダーを追加される型があります。

From は、その共有サーバーに設定してある自社ドメインにします。別ドメインの From は、サーバー側で拒否されたり、受信側で迷惑メールになったりします。example.com は置き換えです。

<?php
if (($_SERVER["REQUEST_METHOD"] ?? "") !== "POST") {
  http_response_code(405);
  exit;
}

header("Content-Type: application/json; charset=UTF-8");

if (trim((string) ($_POST["company"] ?? "")) !== "") {
  echo json_encode(["ok" => true]);
  exit;
}

$to = "info@example.com";
$name = trim((string) ($_POST["name"] ?? ""));
$email = trim((string) ($_POST["email"] ?? ""));
$message = trim((string) ($_POST["message"] ?? ""));
$email = str_replace(["\r", "\n"], "", $email);

if (
  $name === "" ||
  $message === "" ||
  filter_var($email, FILTER_VALIDATE_EMAIL) === false
) {
  http_response_code(400);
  echo json_encode(["ok" => false]);
  exit;
}

mb_language("Japanese");
mb_internal_encoding("UTF-8");

$body = "名前: {$name}\nメール: {$email}\n\n{$message}";
$headers = "From: noreply@example.com\r\nReply-To: {$email}";
$ok = mb_send_mail($to, "サイトからの問い合わせ", $body, $headers);

http_response_code($ok ? 200 : 500);
echo json_encode(["ok" => $ok]);

mb_send_mail にしているのは、件名の日本語です。mail のままだと、共有サーバー上で件名が文字化けすることがあります。本文も化けるときは、サーバーのPHPメールの案内に合わせます。このサンプルは、添付も、宛先の選択もありません。

上げたあとに、消えていないか

ビルドして、out/contact.php があるかを先に見ます。無いなら、public/ に置いていません。

アップロードは、out の中身を公開ディレクトリの直下へ置きます。out フォルダごと上げると、URLが /out/contact.php になります。

--delete 付きの rsync は、手元の out に無いファイルをサーバーから消します。PHPを public/ に入れていれば、out に含まれるので残ります。サーバー上でだけ編集したファイルは消えます。差分をサーバーで直す運用には向きません。

届いたかの確認は、画面の外

JSONが ok: true でも、メールが担当者に届いたことにはなりません。見るのは次です。

  1. 自分の別アドレスから1通送り、画面が「受け付けました」になるか。
  2. 通知先の受信箱と、迷惑メール。
  3. Reply-To に、入力したアドレスが入っているか。返信がその人に戻るか。

Microsoft 365 でメールを受けているドメインは、ここがもう一段あります。SPFが include:spf.protection.outlook.com だけの -all だと、共有サーバーの PHP から出したメールは、送信元として認められないことがあります。受信箱に無いときは、迷惑メールのあとで、SPFの1行にサーバー側の include が含まれるかを見ます。TXTを2行に分けません。値は、契約中のサーバーと Microsoft 365 の案内にある文字列を使います。この記事ではレコードの書き方までは出しません。

外部サービス(Formspree など)にする場合も、宛先の確認と、送信元ドメインの扱いは残ります。APIキーをクライアントのJSへ置かない、は microCMS と同じです。

画面だけ直したフォームと、受け口を一緒に上げたフォーム

悪い例:API Routes のまま書き出す

ローカルの next dev では送れる。output: "export" のビルドで失敗する。失敗を避けてAPIのファイルを消すと、本番のボタンだけが残る。

良い例:public の PHP と、テスト1通がセット

public/contact.php が out に含まれる。宛先は固定。From は自社ドメイン。別アドレスから1通送り、受信箱か迷惑メールかを記録してから納品する。

静的Next.jsのフォームチェックシート

確認

見ること

問題だったときの第一手

ビルド

app/api が残っていない

受け口を PHP か外部サービスへ移す

ファイル

out/contact.php がある

public/contact.php に置く

アップロード

公開URLが /contact.php

out の中身を直下へ。フォルダごと上げない

再アップ

--delete で PHP が消えない

public/ に含めてからビルドする

宛先

POSTの値で変わらない

PHP内の固定アドレスだけにする

From

サーバーに設定した自社ドメイン

案内どおりの送信元に変える

ヘッダー

Reply-To に改行が無い

入力から改行を除く

受信

受信箱と迷惑メール

テストの日時を記録する

SPF

サーバー送信が1行に含まれる

TXTを増やさず、include を足す

キー

ブラウザから管理APIを叩いていない

秘密はビルド環境かサーバー側だけ

まとめ:書き出したHTMLは、メールを出せない

静的サイトが速いのは、リクエストのたびにサーバー処理をしないからです。問い合わせは、その例外として、PHPか外部サービスの1本だけ足します。完了の文言は、届いた証明ではありません。

共有サーバーへのフォーム追加、再アップロードで消えない置き方の相談は、相談・お問い合わせからどうぞ。