はじめに
ウェブサイトの内容がほぼ静的コンテンツである場合、事前生成して配信したいのが普通ですが、大概のサイトは 問い合わせフォーム がついており、これのせいで静的コンテンツで閉じなくなるケースが多いです。極端な話、そのためだけにWordPressを入れてしまう場合もあると思います。(悲劇の始まり)
今回は サイトを静的コンテンツ配信にしたまま問い合わせフォームをカスタム要素で生やす 方法を紹介します。
<script src="https://inquiry-my-site.example.com/js/inquiry-form.js" async>
</script>
<inquiry-form></inquiry-form>
構成
- 静的サイト (静的HTML/CSS/JSを配信)
- 問い合わせフォーム部分 (AWS Lambda上などに作成)
- カスタム要素配布エンドポイント
- フォーム送信APIエンドポイント
- Cloudflare Turnstile: ロボット除け
問い合わせ受付メール送信などは本記事の対象外としています。Turnstile は実構成ではほぼ必要になるので追加していますが、それ自体は特に重要ではありません。
全体の流れは以下のようになります。
具体的な実装方法について
静的サイト
冒頭で示した通り、問い合わせページに以下のHTMLを埋め込むだけです。
<script src="https://inquiry-my-site.example.com/js/inquiry-form.js" async></script>
<inquiry-form></inquiry-form>
これで終わりです。静的ページ側は問い合わせフォームの内部メカニズムを知る必要はありません。
カスタム要素のタグ名には ハイフンが必須 です。
スタイルは <inquiry-form> 内のDOM要素について静的サイト側からあてることができます。
inquiry-form .inquiry-form__field {
margin-block-end: 1.5rem;
}
/* サイトの既存ボタンスタイルをそのまま流用できる */
inquiry-form button[type="submit"] {
background: var(--site-accent);
border-radius: var(--site-radius);
}
ちなみにカスタム要素といえばShadow DOMがつきもので、外部のセレクタによる意図しないスタイル衝突を防げます。今回は問い合わせフォームをサイトのデザインに馴染ませる必要があるため使いません。
問い合わせフォーム
今回はHonoで実装していきます。カスタム要素の配布とフォーム受付APIを、1つのHonoアプリとしてLambdaに載せます。
import { Hono } from "hono";
import { cors } from "hono/cors";
// 'hono': '4.13.2' / Node.js 24 / esbuild 0.28.2 で検証
const app = new Hono();
// 静的サイトのオリジンのみ許可する
const ALLOWED_ORIGINS = ["https://www.example.com"];
app.use("/api/*", cors({
origin: ALLOWED_ORIGINS,
allowMethods: ["POST", "OPTIONS"],
}));
export default app;
カスタム要素配布
サイトキーとエンドポイントを埋め込んで配ります。配信とAPIを同じサービスに置いておけば、利用側は <script> タグ1行を貼るだけで済み、送信先のURLやサイトキーを別途知らせる必要がなくなります。
import app from "./index.ts";
// カスタム要素の実体。バックティックの入れ子を避けるため別ファイルにしておく。
// esbuild でバンドルするとファイル内容が文字列としてインライン化される
// (この import attribute は Node 単体では動かないのでバンドル必須)
import ELEMENT_SOURCE from "./inquiry-form.js" with { type: "text" };
const SCRIPT = (siteKey: string, endpoint: string) => `
const SITE_KEY = ${JSON.stringify(siteKey)};
const ENDPOINT = ${JSON.stringify(endpoint)};
${ELEMENT_SOURCE}
`;
app.get("/js/inquiry-form.js", (c) => {
// Lambda アダプタでは c.env は event/context なので、環境変数は process.env から読む。
// 自オリジンは c.req.url から取らない。API Gateway / CloudFront 経由だと
// 公開URLと一致しないことがあるため、公開オリジンは環境変数で明示する
const body = SCRIPT(process.env.TURNSTILE_SITE_KEY!, `${process.env.PUBLIC_ORIGIN!}/api/inquiry`);
return c.body(body, 200, {
"content-type": "text/javascript; charset=utf-8",
// 古い JS が新しい API を叩くのを避けるため、キャッシュはさせない
"cache-control": "no-store",
});
});
配られるカスタム要素の実体はこれだけです。SITE_KEY と ENDPOINT は上記の通り配信時に埋め込まれます。
// Turnstile の api.js を読み込む。
// 暗黙的な自動描画は api.js ロード直後の一瞬しか走査しないため、後から差し込む
// カスタム要素とは相性が悪い。render=explicit で止めて turnstile.render() を使う。
// また動的に挿入した script は async 扱いで turnstile.ready() が使えないため onload で待つ。
const turnstileReady = new Promise((resolve, reject) => {
const callbackName = "__onloadInquiryFormTurnstile";
window[callbackName] = () => resolve(window.turnstile);
const script = document.createElement("script");
script.src = `https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit&onload=${callbackName}`;
script.onerror = () => reject(new Error("failed to load turnstile"));
document.head.append(script);
});
const TEMPLATE = `
<form class="inquiry-form">
<label class="inquiry-form__field">お名前
<input name="name" required>
</label>
<label class="inquiry-form__field">メールアドレス
<input name="email" type="email" required>
</label>
<label class="inquiry-form__field">お問い合わせ内容
<textarea name="body" required maxlength="4000"></textarea>
</label>
<label class="inquiry-form__field">添付ファイル
<input name="attachment" type="file" accept=".pdf,.png,.jpg">
</label>
<div class="inquiry-form__turnstile"></div>
<button type="submit" disabled>送信</button>
<p class="inquiry-form__status" role="status" aria-live="polite"></p>
</form>
`;
class InquiryForm extends HTMLElement {
async connectedCallback() {
// Shadow DOM は使わず light DOM に組み立てる(サイト側のCSSを効かせるため)
this.innerHTML = TEMPLATE;
const form = this.querySelector("form");
const button = this.querySelector("button");
const status = this.querySelector(".inquiry-form__status");
// 要素の参照を直接渡して明示的にレンダリングする
const turnstile = await turnstileReady;
const widgetId = turnstile.render(this.querySelector(".inquiry-form__turnstile"), {
sitekey: SITE_KEY,
callback: () => { button.disabled = false; },
"expired-callback": () => { button.disabled = true; },
});
form.addEventListener("submit", async (event) => {
event.preventDefault();
button.disabled = true; // 二重送信を防ぐ
status.textContent = "送信中...";
try {
// FormData をそのまま送る。添付ファイルもトークンもこれで一緒に飛ぶ
const res = await fetch(ENDPOINT, { method: "POST", body: new FormData(form) });
// 今回の例では受付完了ページにジャンプせず、ボタン近くにステータスを出して終わり
status.textContent = res.ok ? "送信しました" : `送信に失敗しました (${res.status})`;
} catch {
status.textContent = "送信に失敗しました (通信エラー)";
} finally {
// Turnstile のトークンは使い切り。リセットしないと再送信が必ず失敗する
turnstile.reset(widgetId);
}
});
}
}
customElements.define("inquiry-form", InquiryForm);
上記のコードでカスタム要素定義のための *.js を配信し、静的サイト側から <script src="..."> でロードしてもらいます。送信ボタンはテンプレート側で disabled にしてあるため、Turnstile のロードが終わってウィジェットが解かれるまでは送信できません。
フォーム受付API
問い合わせフォームからのAPI通信(問い合わせ送信)を受け取るためのAPIエンドポイントです。
import app from "./index.ts";
app.post("/api/inquiry", async (c) => {
// multipart/form-data で来るので parseBody で受ける
const form = await c.req.parseBody();
const { name, email, body, attachment } = form;
// Turnstile が差し込んだ hidden input の名前でトークンを受け取る
const token = form["cf-turnstile-response"];
if (typeof token !== "string" || !(await verifyTurnstile(process.env.TURNSTILE_SECRET_KEY!, token))) {
return c.json({ error: "verification failed" }, 400);
}
// TODO メールなどで外部に通知
console.log({ name, email, body, attachment });
return c.json({ ok: true });
});
const verifyTurnstile = async (secret: string, token: string, remoteip?: string) => {
const res = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ secret, response: token, ...(remoteip ? { remoteip } : {}) }),
});
const data = (await res.json()) as { success: boolean };
return data.success === true;
};
テンプレートの accept=".pdf,.png,.jpg" はファイル選択ダイアログを絞り込むだけのヒントで、任意の拡張子・MIMEタイプが送られてきます。ファイルの種類やサイズはサーバ側で必ず検証してください。
考慮すべきこと
CORS
カスタム要素の JS の送信元は静的サイトのページになります。しかしヘッダなど追加しない単純な multipart/form-data の場合は特にプリフライトなど無しに送信できてしまいます。ヘッダを付ける場合は気をつけて下さい。
CSP
静的サイト側で CSP を設定している場合、フォーム配信元と Turnstile の分を追加する必要があります。
Content-Security-Policy:
script-src 'self' https://inquiry-my-site.example.com https://challenges.cloudflare.com;
frame-src https://challenges.cloudflare.com;
connect-src 'self' https://inquiry-my-site.example.com;
ファイル添付の制約
本記事のコードは添付ファイルを FormData に載せて API へ直接POSTしています。
手軽ですが、実行環境がLambdaの場合はリクエストサイズの上限があり、かつファイルがオンメモリに載るため実用に耐えません。ちゃんとやる場合は以下のアプローチが考えられます。
- Lambdaを諦めてアップロード処理をオンメモリにしない手法で行う
- Turnstile トークンをAPIリクエストヘッダに入れてもらい、検証が通った場合のみ、ボディを受け取り、ストリーミングでS3にアップロードする
- S3へクライアントから直接アップロードさせる
- 添付ファイルより前に問い合わせフォーム回答をAPIで受け取って、レスポンスとしてアップロード用の署名付きS3 URLを返却し、クライアントから別途そちらにアップロードしてもらいましょう
後者の流れは以下のようになります。最初のフォーム送信に添付ファイルを含めないのがポイントで、これでリクエストサイズもオンメモリの問題も回避できます。Turnstile の検証も1回で済みます。
ほかに考えられるアプローチ
- 既製品: Google フォーム, Microsoft Forms など既製品は匿名ユーザから添付ファイルを受け取れないケースが多いです
- iframe: 単純ですが、見た目の一体性などに難があります。カスタム要素であればサイトと一体になったフォームは構築可能です
- ページ内の特定要素にフックするスクリプトをロードする: ほぼ同等のことを実現できますが、明快さは今回手法のほうが上と言えます
まとめ
WordPressなどを立てず、 カスタム要素を使って静的サイトのまま問い合わせフォームを構築する手法 を見てきました。
フォーム配信側で考慮すべき点はまだまだ多いですが、サイトごとに基準が異なるのと煩雑になるので省いています。実運用に持っていく際は、本記事で挙げた観点を出発点にAIエージェントへレビューさせるのが手軽でおすすめです。
ハッピーなWeb制作をしていきましょう!
