AWS 2026年6月19日

広告LPのリード獲得基盤をS3+CloudFront+Lambda+Apps Scriptで組んだ現場構成

XECIN LambdaCloudFrontCORS技術検証

広告を出すことになって、まず困ったのが「リードをどこで受けるか」でした。

サイト本体はすでに Astro + S3 + CloudFront の静的配信に寄せてあって、サーバーは持っていません。ここに広告LPを追加して、フォーム送信からリード管理・計測連携までを動かす必要が出てきた。サーバーレスで後付けするしかないのですが、いざ組んでみると「静的サイトにフォーム処理を足す」というだけの話が、思ったより設計判断の連続でした。

今回はその構成と、運用を始めてから分かったことを残しておきます。


静的配信にフォーム処理を後付けする全体像

最初に決めたのは、LP の配信レイヤーには一切手を入れないという方針でした。LP はあくまで S3 に置いた静的HTMLで、CloudFront から配信する。フォーム送信だけを別の経路に逃がす。

構成を整理するとこうなります。

レイヤー使うもの役割
配信S3 + CloudFrontLP の静的HTML/CSS/JSを配信。サーバーは持たない
フォーム受け(広告LP)Google Apps Script送信を受けて Sheets に追記、自動返信・管理者通知
フォーム受け(通常の問い合わせ)Lambda + API キー一般の /contact 用。JSON + x-api-key で受ける
計測GTM + GA4form_submit イベントを発火、拡張CV連携

ここで「なぜ広告LPは Lambda ではなく Apps Script なのか」と思われるはずです。私も最初は通常の問い合わせと同じく Lambda に寄せるつもりでした。後述しますが、CORS で一度つまずいて、結果的に経路を分けることになりました。

コスト面の話をしておくと、Apps Script は実行回数の上限内なら無料、Lambda も月数百〜数千リードの規模では実質ゼロに近い。リード基盤のために月額固定費が増えるのは避けたかったので、運用を考えると「使った分だけ」のサーバーレスに寄せたのは正解だったと考えています。サーバーを1台立てれば月数千円は固定でかかるところを、ほぼ0円で回せている。


経路の出し分けはエンドポイントの環境変数で切り替える

LP 側のコードには、送信先のURLをハードコードしていません。ビルド時に環境変数を注入して切り替えています。

環境変数用途
PUBLIC_AI_WEB_CONTACT_ENDPOINT広告LP(/lp/ai-web)の送信先。Apps Script の Web App URL
PUBLIC_CONTACT_ENDPOINT通常の /contact の送信先。Lambda
PUBLIC_CONTACT_API_KEYLambda 用の API キー(Apps Script では使わない)

送信処理の側では、エンドポイントのURLのパターンを見て送り方を自動で切り替えるようにしました。script.google.com を含むかどうかで Apps Script 経路か Lambda 経路かを判定しています。

// 送信先URLのパターンで送り方を出し分ける
function buildRequestInit(endpoint, payload) {
  const isAppsScript = endpoint.includes('script.google.com');

  if (isAppsScript) {
    // Apps Script へは text/plain で送る(理由は後述)
    return {
      method: 'POST',
      headers: { 'Content-Type': 'text/plain;charset=utf-8' },
      body: JSON.stringify(payload),
    };
  }

  // Lambda へは JSON + API キー
  return {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'x-api-key': import.meta.env.PUBLIC_CONTACT_API_KEY,
    },
    body: JSON.stringify(payload),
  };
}

URL のパターンで分岐しているのは、正直あまり上品なやり方ではありません。本来は「経路の種別」を明示的なフラグで持つべきだと思っています(改善の余地あり:isAppsScript を文字列マッチではなく、ビルド時に経路種別を表す環境変数で渡す形にしたい)。ただ、運用上は送信先が増えるたびに分岐条件を足すより、URL で判定したほうが設定ミスが起きにくかった、というのが今の落としどころです。


なぜ Apps Script だけ text/plain で送るのか

ここが一番ハマったところです。

最初、広告LPのフォームも通常の /contact と同じく Content-Type: application/json で Apps Script に POST していました。ローカルでは動く。でも本番ドメイン(CloudFront 配信)から送ると、ブラウザのコンソールに CORS エラーが出てフォームが通らない。

原因は preflight リクエストでした。application/json のような「単純リクエスト」に当たらない Content-Type で送ると、ブラウザは本番のPOSTの前に OPTIONS リクエスト(preflight)を投げます。ところが Apps Script の Web App は、この OPTIONS に対して期待する CORS ヘッダを返してくれない。結果、ブラウザが本番のPOSTをブロックする。

// 起きていたこと
ブラウザ → OPTIONS /exec (preflight)  → Apps Script が CORS ヘッダを返さない
ブラウザ → ここでブロック。POST は飛ばない

回避策として、Content-Type を text/plain;charset=utf-8 に変えました。これは「単純リクエスト」と見なされる Content-Type なので、preflight が発生しません。ブラウザは preflight 抜きでいきなりPOSTを投げる。中身は今まで通り JSON 文字列で、受け取る Apps Script 側で JSON.parse すれば良い。

// Apps Script 側(doPost)。text/plain で来た JSON 文字列をパースする
function doPost(e) {
  const payload = JSON.parse(e.postData.contents);
  // 以降は通常通りリードとして処理(Sheets追記・自動返信・管理者通知)
  handleFormSubmit(payload);
  return ContentService
    .createTextOutput(JSON.stringify({ ok: true }))
    .setMimeType(ContentService.MimeType.JSON);
}

一方で Lambda 側は API Gateway で OPTIONS にきちんと CORS ヘッダを返す設定ができるので、application/json + x-api-key のままで問題ありません。だからこそ経路を分けることになった、というのが実際の経緯です。Apps Script の制約に引っ張られて通常の問い合わせまで text/plain に揃える必要はない、と考えました。

ドキュメントには「Apps Script は CORS が面倒」と一行で書いてあったりしますが、実際にどのヘッダで詰まるのかは送ってみないと分からない類のものでした。


本番とプレビューの両方で送信〜計測まで通す

このサイトは PR ごとにプレビュー環境が立ち上がる仕組みになっています。フォーム送信のような外部連携は、プレビューと本番で挙動がずれやすいので、両方で送信から計測到達まで一通り確認する手順を固定化しました。

私がやっている確認の順序はこうです。

  1. プレビュー環境でフォーム送信 — PR のプレビューURLからテスト用メールアドレスで送信する。送信先は本番と同じ Apps Script を向くので、Sheets にテスト行が増えるのを確認する
  2. Sheets への追記を確認 — リード管理表に行が追加され、想定した列(流入元・プラン関心など)に値が入っているかを見る
  3. dataLayer の push を確認 — ブラウザのコンソールで form_submit イベントが期待したスキーマで push されているかを見る
  4. GA4 リアルタイムで到達確認 — GTM プレビューを通して GA4 のリアルタイムにイベントが届くまでを追う
  5. 本番で同じことを一度だけ実施 — マージ後、本番URLでも同じ流れを1回通す

プレビューでも送信先が本番の Apps Script を向いているのは、設計上の割り切りです。テスト用のメールアドレスを決めておき、管理者通知が実在の受信箱に飛ぶことを逆に確認材料にしています。運用を考えると、送信先までモックにしてしまうと「本番だけ動かない」を取りこぼすので、ここは実物に通すほうが安心だと考えています。


落とし穴:form_submit が GA4 で約2倍に計上された

運用を始めてしばらくして、数字が合わないことに気づきました。

GA4 で見る form_submit の回数が、Sheets に実際に積み上がっているリード件数のおよそ2倍になっている。最初は「二重送信のバグか」と疑って送信処理を何度も見直したのですが、Sheets 側は重複していない。つまりリード自体は1件で、計測イベントだけが2回数えられていました。

原因の切り分けにそれなりに時間を使った末、これはフォーム送信処理とGTMのトリガー条件が二重に噛んでいる類の計上だと判断しました。送信ロジック側を下手にいじると、今度は逆に計測が落ちるリスクがある。触ってはいけない設定もあるので、無理に「GA4 の数字を1倍にする」方向で直すのはやめました。

代わりにやったのは、主指標を GA4 の form_submit から Sheets のリード件数に切り替えるという運用判断です。

指標扱い理由
Sheets のリード件数主指標実体と1対1。重複しない。最終的な商談母数に直結する
GA4 の form_submit補助指標(傾向把握)約2倍計上のクセを承知の上で、流入経路の比較に使う

数字のクセそのものを消すより、「どの数字を信じるか」を決めるほうが、現場では早く前に進めることがあります。GA4 の値は経路ごとの相対比較(どの広告から来た人が多いか)には使えるので、絶対数の指標としては捨てて、傾向把握に役割を限定しました。この判断は社内にも明示的に共有して、レポートを見る人が「GA4 とリード数が合わない」と毎回つまずかないようにしています。


振り返って

組み終わってから思うのは、「静的サイトにフォームを足すだけ」の難しさは、配信や言語ではなく経路の境界に出る、ということでした。CORS にしても二重計上にしても、つまずいたのは S3 でも CloudFront でもなく、その先の Apps Script や GA4 とのつなぎ目です。

経路をURLパターンで出し分けている今の実装は、動いてはいるものの、送信先が増えたときに分岐がじわじわ複雑になる懸念があります。ここは経路種別を明示的に持つ形に寄せていきたいと考えています。

form_submit の二重計上も、今は運用でかわしている状態で、原因の本丸には手を入れていません。広告の出稿規模が大きくなって相対比較だけでは足りなくなったら、計測構成のほうを正面から見直す必要が出てくるはずです。そのときに今回の「どこで詰まったか」の記録が効いてくると思うので、つなぎ目の判断はこうして残しておくようにしています。