SSGでサイトを運営していると、「問い合わせフォームだけのためにサーバーを立てるのか」という問題に直面します。SaaSを使えば簡単ですが、月額コストが積み上がります。個人開発や小規模サイトでは、できれば月額0円で完結させたいところです。
ECサイトの問い合わせフォームを実装した際、Google Apps Script(GAS)+ Google Spreadsheet + Gmailという構成で、サーバーコスト0円の問い合わせ機能を実現しました。入力 → 確認 → 完了の3ステップフォームで、データはSpreadsheetに蓄積され、Gmail通知も自動で飛びます。
ただし、「GASにPOSTするだけ」と思って始めると、CORSエラーやGAS特有のデバッグ地獄にハマります。本記事では、実装で踏んだ落とし穴と解決策を記録します。
なぜGASを選んだか:代替手段との比較
SSGに問い合わせ機能を追加する手段は複数あります。
| サービス | 月額コスト | 特徴 | 懸念 |
|---|---|---|---|
| GAS + Spreadsheet | 0円 | Google無料枠で完結、データがSpreadsheetに残る | デプロイ・CORS周りにクセがある |
| SendGrid | 無料枠あり([要確認]通/月) | メール送信特化、API品質が高い | フォームデータの蓄積は別途必要 |
| Netlify Forms | 無料枠あり([要確認]件/月) | Netlifyホスティング利用者には最も簡単 | Netlify以外のホスティングでは使えない |
| Formspree | 無料枠あり([要確認]件/月) | SSGとの相性が良い | 無料枠の件数制限が厳しい |
| Googleフォーム埋め込み | 0円 | 最も手軽 | デザインのカスタマイズ性がほぼゼロ |
GASを選んだ決め手は3つです。
- 完全無料: Googleアカウントさえあれば追加コスト0円
- データ蓄積が自動: Spreadsheetに問い合わせ内容が残るので、CSVエクスポートも集計も簡単
- 通知が標準機能: GAS内で
GmailApp.sendEmail()を呼ぶだけで通知メールが飛ぶ
アーキテクチャ
1[ユーザーのブラウザ]2 │3 │ (1) 入力 → 確認 → 送信ボタン4 │5 ▼6[Astro SSG(静的HTML + JS)]7 │8 │ (2) fetch POST(JSON)9 │10 ▼11[GAS Web App(doPost)]12 │13 ├── (3) Google Spreadsheetに行追加14 │15 └── (4) GmailApp.sendEmail()で通知Astroはビルド時に静的HTMLを生成するだけなので、フォーム送信のロジックはクライアントサイドJavaScriptでfetchします。GAS側はWeb Appとしてデプロイし、doPost(e)関数でリクエストを受け取ります。
3ステップフォームの実装
フォームを「入力 → 確認 → 完了」の3ステップに分けた理由は、誤送信の防止です。確認画面なしの1ステップフォームは実装が楽ですが、ユーザーが入力内容を見直す機会がありません。
ステップ管理の考え方
1// フォームの状態を3段階で管理2type FormStep = "input" | "confirm" | "complete";3
4// 各ステップで表示する内容を切り替える5// input: フォーム入力欄を表示6// confirm: 入力内容の確認表示 + 送信ボタン + 戻るボタン7// complete: 送信完了メッセージ確認画面では入力値を読み取り専用で表示し、「戻る」ボタンで入力画面に戻れるようにします。送信は確認画面の「送信」ボタンを押したときだけ実行されます。
GAS側:doPost関数
1function doPost(e) {2 try {3 const data = JSON.parse(e.postData.contents);4
5 // Spreadsheetに書き込み6 const sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();7 sheet.appendRow([8 new Date(),9 data.name,10 data.email,11 data.message,12 ]);13
14 // Gmail通知15 GmailApp.sendEmail(15 collapsed lines
16 "your-email@example.com",17 "問い合わせがありました: " + data.name,18 "名前: " + data.name + "\nメール: " + data.email + "\n\n" + data.message19 );20
21 return ContentService22 .createTextOutput(JSON.stringify({ result: "success" }))23 .setMimeType(ContentService.MimeType.JSON);24
25 } catch (error) {26 return ContentService27 .createTextOutput(JSON.stringify({ result: "error", message: error.toString() }))28 .setMimeType(ContentService.MimeType.JSON);29 }30}クライアント側:fetch POST
1const response = await fetch(GAS_WEB_APP_URL, {2 method: "POST",3 headers: { "Content-Type": "application/json" },4 body: JSON.stringify({ name, email, message }),5});CORSとの戦い:最大のハマりポイント
GASのWeb Appは、CORSの挙動にクセがあります。ローカル開発環境(localhost)からGASにPOSTすると、CORSエラーで弾かれます。
問題の構造
- ブラウザが
localhost:4321からGASのURLにPOSTリクエストを送る - GASはリダイレクトレスポンスを返す(
302) - ブラウザがリダイレクト先にリクエストを送ると、CORSヘッダーがない → エラー
解決策:mode: "no-cors"は使うな
fetchにmode: "no-cors"を指定するとエラーは消えますが、レスポンスが読めなくなります(opaque response)。成功・失敗の判定ができないので、本質的な解決にはなりません。
実際に効果があった対策は以下の通りです。
- GASのデプロイ設定を「全員がアクセス可能」にする: デプロイ時に「アクセスできるユーザー」を「全員」に設定します。これがないとCORSエラーになります
- GASを再デプロイする: コードを変更していなくても、デプロイ設定を変更した場合は新しいデプロイを作成する必要があります。「デプロイを管理」→「新しいデプロイ」で新しいURLが発行されます。古いURLのまま設定だけ変えても反映されません
redirect: "follow"を明示する: fetchのオプションでredirect: "follow"を指定し、リダイレクトに自動追従させます
1const response = await fetch(GAS_WEB_APP_URL, {2 method: "POST",3 body: JSON.stringify({ name, email, message }),4 redirect: "follow",5});特に2番目の「再デプロイ」が最大の落とし穴です。GASのエディタ上でコードを保存しただけではWeb Appに反映されません。毎回「新しいデプロイ」を作成する必要があります。
GASデバッグの罠:実際に遭遇したエラー
エラー1: Cannot read properties of undefined (reading 'postData')
GASのエディタ上でdoPostを直接実行すると発生します。これは当然で、エディタからの実行ではe(イベントオブジェクト)が渡されないからです。
1// GASエディタから直接実行 → e は undefined → e.postData でエラー2function doPost(e) {3 const data = JSON.parse(e.postData.contents); // ここで死ぬ4}対策: GASのdoPostはエディタからテストできません。実際にHTTPリクエストを送ってテストします。ローカルからcurlで確認するか、デプロイ後のURLに直接POSTします。
1curl -L -X POST "https://script.google.com/macros/s/YOUR_DEPLOY_ID/exec" \2 -H "Content-Type: application/json" \3 -d '{"name":"テスト","email":"test@example.com","message":"テスト送信"}'エラー2: setHeaders is not a function
1// これはエラーになる2ContentService.createTextOutput(json)3 .setMimeType(ContentService.MimeType.JSON)4 .setHeaders({ "Access-Control-Allow-Origin": "*" }); // ← setHeadersは存在しないGASのContentServiceにはCORSヘッダーを設定するメソッドがありません。CORSはGAS側のコードでは制御できず、デプロイ設定(前述の「全員がアクセス可能」)で制御されます。StackOverflowなどで見かけるsetHeadersの情報は誤りです。
UX改善:送信中の体験を守る
GASへのPOSTは、レスポンスが返るまで数秒かかることがあります。この間にユーザーが離脱すると、送信が中途半端に終わる可能性があります。
ローディング表示
送信ボタンを押した後に「送信処理中です。ページを閉じずにお待ち下さい。」というメッセージを表示します。ボタンも非活性にして二重送信を防ぎます。
1// 送信中のUI制御2const submitButton = document.querySelector<HTMLButtonElement>("#submit-btn");3const loadingMessage = document.querySelector<HTMLElement>("#loading-message");4
5if (submitButton && loadingMessage) {6 submitButton.disabled = true;7 loadingMessage.style.display = "block";8 loadingMessage.textContent = "送信処理中です。ページを閉じずにお待ち下さい。";9}ブラウザ閉じ防止
送信処理中にブラウザを閉じようとした場合、確認ダイアログを表示します。
1// 送信中のみ離脱を警告2let isSubmitting = false;3
4window.addEventListener("beforeunload", (event) => {5 if (isSubmitting) {6 event.preventDefault();7 }8});9
10// 送信処理の前後でフラグを切り替え11isSubmitting = true;12await fetch(GAS_WEB_APP_URL, { /* ... */ });13isSubmitting = false;beforeunloadは送信中のみ発火させます。常時有効にするとユーザー体験を損ねます。
セキュリティ:サーバーなしでスパムをどう防ぐか
サーバーレス構成の弱点は、バックエンドでのバリデーションやレート制限が難しいことです。
最低限の対策
- ハニーポットフィールド: CSSで非表示にした入力欄を設置し、値が入っていたらbot判定します。人間には見えませんが、botは機械的に全フィールドを埋めます
1<!-- CSSで display: none にする -->2<input type="text" name="website" style="display: none" tabindex="-1" autocomplete="off" />1// GAS側でチェック2function doPost(e) {3 const data = JSON.parse(e.postData.contents);4 if (data.website) {5 // ハニーポットに値がある → bot6 return ContentService7 .createTextOutput(JSON.stringify({ result: "success" }))8 .setMimeType(ContentService.MimeType.JSON);9 // 成功レスポンスを返すが、実際には保存しない10 }11 // 通常の処理...12}- クライアント側バリデーション: メールアドレスの形式チェック、必須項目の確認。突破は容易ですが、カジュアルなスパムは防げます
- reCAPTCHA: Googleが提供するbot判定サービス。無料枠で十分ですが、GAS側での検証にはreCAPTCHAのSecret KeyをGASのPropertiesServiceに保存する必要があります [要確認: reCAPTCHA v3の無料枠上限]
コスト比較
| 構成 | 月額 | 備考 |
|---|---|---|
| GAS + Spreadsheet + Gmail | 0円 | Google無料アカウントの範囲内 |
| Netlify Forms(無料枠) | 0円 | Netlifyホスティング限定、[要確認]件/月 |
| Formspree(無料枠) | 0円 | [要確認]件/月の制限 |
| SendGrid + 自前DB | [要確認]円〜 | フォームデータの蓄積は別途実装が必要 |
| VPS(問い合わせ用API) | 500〜1,500円/月 | 過剰スペック。SSGの利点が薄れる |
GAS構成の上限はGoogleアカウントのGAS実行クォータに依存します。GASの無料枠では、1日あたりのメール送信数やスクリプト実行時間に上限があります [要確認: 具体的なクォータ値]。個人サイトや小規模ECの問い合わせ量であれば、まずクォータに到達することはありません。
学んだこと
1. GASのデプロイは「新しいデプロイ」が必須
コード変更後に「デプロイを管理」から既存デプロイを更新しただけでは反映されないことがあります。確実に反映するには新しいデプロイを作成します。CORSエラーで2時間溶かした原因がこれでした。
2. doPostはエディタからテストできない
GASエディタの「実行」ボタンはイベントオブジェクトを渡しません。e.postDataがundefinedになるのは仕様通りの挙動であり、バグではありません。curlでテストする習慣をつけましょう。
3. ContentServiceにsetHeadersは存在しない
CORSヘッダーをGASのコードで設定しようとしても無駄です。デプロイ設定の「全員がアクセス可能」がCORS制御のすべてです。
4. 送信中のUXを軽視しない
GASのレスポンスは速くありません。ローディング表示と離脱防止がないと、ユーザーが送信中にページを閉じてしまうリスクがあります。
まとめ
GAS × Astro SSGの問い合わせフォーム構成で重要なのは以下の4点です。
- 月額0円で問い合わせ機能が完結する: GAS + Spreadsheet + Gmailの組み合わせで、サーバーコストもSaaS費用も不要です
- CORSの解決はデプロイ設定がすべて: コード側でCORSヘッダーを設定する方法は存在しません。「全員がアクセス可能」+「新しいデプロイの作成」で解決します
- GASのデバッグにはcurlを使う: エディタ上の実行ではイベントオブジェクトが渡されないため、実際のHTTPリクエストでテストします
- 送信中のUXを必ず実装する: ローディング表示とbeforeunloadによる離脱防止で、送信の完了率を守ります