45395 - シコウサクゴ -

GAS × Astro SSGで問い合わせフォームを実装する:サーバーレスで月額0円

2026-04-11
プログラミング
Astro
GAS
Google Apps Script
SSG
フォーム
サーバーレス
Last updated:2026-04-12
14 Minutes
2714 Words

SSGでサイトを運営していると、「問い合わせフォームだけのためにサーバーを立てるのか」という問題に直面します。SaaSを使えば簡単ですが、月額コストが積み上がります。個人開発や小規模サイトでは、できれば月額0円で完結させたいところです。

ECサイトの問い合わせフォームを実装した際、Google Apps Script(GAS)+ Google Spreadsheet + Gmailという構成で、サーバーコスト0円の問い合わせ機能を実現しました。入力 → 確認 → 完了の3ステップフォームで、データはSpreadsheetに蓄積され、Gmail通知も自動で飛びます。

ただし、「GASにPOSTするだけ」と思って始めると、CORSエラーやGAS特有のデバッグ地獄にハマります。本記事では、実装で踏んだ落とし穴と解決策を記録します。

なぜGASを選んだか:代替手段との比較

SSGに問い合わせ機能を追加する手段は複数あります。

サービス月額コスト特徴懸念
GAS + Spreadsheet0円Google無料枠で完結、データがSpreadsheetに残るデプロイ・CORS周りにクセがある
SendGrid無料枠あり([要確認]通/月)メール送信特化、API品質が高いフォームデータの蓄積は別途必要
Netlify Forms無料枠あり([要確認]件/月)Netlifyホスティング利用者には最も簡単Netlify以外のホスティングでは使えない
Formspree無料枠あり([要確認]件/月)SSGとの相性が良い無料枠の件数制限が厳しい
Googleフォーム埋め込み0円最も手軽デザインのカスタマイズ性がほぼゼロ

GASを選んだ決め手は3つです。

  1. 完全無料: Googleアカウントさえあれば追加コスト0円
  2. データ蓄積が自動: Spreadsheetに問い合わせ内容が残るので、CSVエクスポートも集計も簡単
  3. 通知が標準機能: 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段階で管理
2
type FormStep = "input" | "confirm" | "complete";
3
4
// 各ステップで表示する内容を切り替える
5
// input: フォーム入力欄を表示
6
// confirm: 入力内容の確認表示 + 送信ボタン + 戻るボタン
7
// complete: 送信完了メッセージ

確認画面では入力値を読み取り専用で表示し、「戻る」ボタンで入力画面に戻れるようにします。送信は確認画面の「送信」ボタンを押したときだけ実行されます。

GAS側:doPost関数

1
function 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.message
19
);
20
21
return ContentService
22
.createTextOutput(JSON.stringify({ result: "success" }))
23
.setMimeType(ContentService.MimeType.JSON);
24
25
} catch (error) {
26
return ContentService
27
.createTextOutput(JSON.stringify({ result: "error", message: error.toString() }))
28
.setMimeType(ContentService.MimeType.JSON);
29
}
30
}

クライアント側:fetch POST

1
const 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エラーで弾かれます。

問題の構造

  1. ブラウザがlocalhost:4321からGASのURLにPOSTリクエストを送る
  2. GASはリダイレクトレスポンスを返す(302
  3. ブラウザがリダイレクト先にリクエストを送ると、CORSヘッダーがない → エラー

解決策:mode: "no-cors"は使うな

fetchmode: "no-cors"を指定するとエラーは消えますが、レスポンスが読めなくなります(opaque response)。成功・失敗の判定ができないので、本質的な解決にはなりません。

実際に効果があった対策は以下の通りです。

  1. GASのデプロイ設定を「全員がアクセス可能」にする: デプロイ時に「アクセスできるユーザー」を「全員」に設定します。これがないとCORSエラーになります
  2. GASを再デプロイする: コードを変更していなくても、デプロイ設定を変更した場合は新しいデプロイを作成する必要があります。「デプロイを管理」→「新しいデプロイ」で新しいURLが発行されます。古いURLのまま設定だけ変えても反映されません
  3. redirect: "follow"を明示する: fetchのオプションでredirect: "follow"を指定し、リダイレクトに自動追従させます
1
const 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 でエラー
2
function doPost(e) {
3
const data = JSON.parse(e.postData.contents); // ここで死ぬ
4
}

対策: GASのdoPostはエディタからテストできません。実際にHTTPリクエストを送ってテストします。ローカルからcurlで確認するか、デプロイ後のURLに直接POSTします。

Terminal window
1
curl -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
// これはエラーになる
2
ContentService.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制御
2
const submitButton = document.querySelector<HTMLButtonElement>("#submit-btn");
3
const loadingMessage = document.querySelector<HTMLElement>("#loading-message");
4
5
if (submitButton && loadingMessage) {
6
submitButton.disabled = true;
7
loadingMessage.style.display = "block";
8
loadingMessage.textContent = "送信処理中です。ページを閉じずにお待ち下さい。";
9
}

ブラウザ閉じ防止

送信処理中にブラウザを閉じようとした場合、確認ダイアログを表示します。

1
// 送信中のみ離脱を警告
2
let isSubmitting = false;
3
4
window.addEventListener("beforeunload", (event) => {
5
if (isSubmitting) {
6
event.preventDefault();
7
}
8
});
9
10
// 送信処理の前後でフラグを切り替え
11
isSubmitting = true;
12
await fetch(GAS_WEB_APP_URL, { /* ... */ });
13
isSubmitting = false;

beforeunloadは送信中のみ発火させます。常時有効にするとユーザー体験を損ねます。

セキュリティ:サーバーなしでスパムをどう防ぐか

サーバーレス構成の弱点は、バックエンドでのバリデーションやレート制限が難しいことです。

最低限の対策

  1. ハニーポットフィールド: CSSで非表示にした入力欄を設置し、値が入っていたらbot判定します。人間には見えませんが、botは機械的に全フィールドを埋めます
1
<!-- CSSで display: none にする -->
2
<input type="text" name="website" style="display: none" tabindex="-1" autocomplete="off" />
1
// GAS側でチェック
2
function doPost(e) {
3
const data = JSON.parse(e.postData.contents);
4
if (data.website) {
5
// ハニーポットに値がある → bot
6
return ContentService
7
.createTextOutput(JSON.stringify({ result: "success" }))
8
.setMimeType(ContentService.MimeType.JSON);
9
// 成功レスポンスを返すが、実際には保存しない
10
}
11
// 通常の処理...
12
}
  1. クライアント側バリデーション: メールアドレスの形式チェック、必須項目の確認。突破は容易ですが、カジュアルなスパムは防げます
  2. reCAPTCHA: Googleが提供するbot判定サービス。無料枠で十分ですが、GAS側での検証にはreCAPTCHAのSecret KeyをGASのPropertiesServiceに保存する必要があります [要確認: reCAPTCHA v3の無料枠上限]

コスト比較

構成月額備考
GAS + Spreadsheet + Gmail0円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.postDataundefinedになるのは仕様通りの挙動であり、バグではありません。curlでテストする習慣をつけましょう。

3. ContentServiceにsetHeadersは存在しない

CORSヘッダーをGASのコードで設定しようとしても無駄です。デプロイ設定の「全員がアクセス可能」がCORS制御のすべてです。

4. 送信中のUXを軽視しない

GASのレスポンスは速くありません。ローディング表示と離脱防止がないと、ユーザーが送信中にページを閉じてしまうリスクがあります。

まとめ

GAS × Astro SSGの問い合わせフォーム構成で重要なのは以下の4点です。

  1. 月額0円で問い合わせ機能が完結する: GAS + Spreadsheet + Gmailの組み合わせで、サーバーコストもSaaS費用も不要です
  2. CORSの解決はデプロイ設定がすべて: コード側でCORSヘッダーを設定する方法は存在しません。「全員がアクセス可能」+「新しいデプロイの作成」で解決します
  3. GASのデバッグにはcurlを使う: エディタ上の実行ではイベントオブジェクトが渡されないため、実際のHTTPリクエストでテストします
  4. 送信中のUXを必ず実装する: ローディング表示とbeforeunloadによる離脱防止で、送信の完了率を守ります
Article title:GAS × Astro SSGで問い合わせフォームを実装する:サーバーレスで月額0円
Article author:45395
Release time:2026-04-11

記事へのご質問・ご感想をお聞かせください

フィードバックを送る