【実務レベル】共有サーバーの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通送り、画面が「受け付けました」になるか。
- 通知先の受信箱と、迷惑メール。
- 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のフォームチェックシート
確認 | 見ること | 問題だったときの第一手 |
|---|---|---|
ビルド |
| 受け口を PHP か外部サービスへ移す |
ファイル |
|
|
アップロード | 公開URLが |
|
再アップ |
|
|
宛先 | POSTの値で変わらない | PHP内の固定アドレスだけにする |
From | サーバーに設定した自社ドメイン | 案内どおりの送信元に変える |
ヘッダー | Reply-To に改行が無い | 入力から改行を除く |
受信 | 受信箱と迷惑メール | テストの日時を記録する |
SPF | サーバー送信が1行に含まれる | TXTを増やさず、include を足す |
キー | ブラウザから管理APIを叩いていない | 秘密はビルド環境かサーバー側だけ |
まとめ:書き出したHTMLは、メールを出せない
静的サイトが速いのは、リクエストのたびにサーバー処理をしないからです。問い合わせは、その例外として、PHPか外部サービスの1本だけ足します。完了の文言は、届いた証明ではありません。
共有サーバーへのフォーム追加、再アップロードで消えない置き方の相談は、相談・お問い合わせからどうぞ。