GA4「タグが検出されません」をReact+Viteで直した話|生HTMLにgtagを静的埋め込み
概要
今回はGoogle Analytics 4(GA4)の管理画面で「ウェブサイトでGoogleタグが検出されませんでした」と言われたトラブルを直した話を紹介していきます。
原因はReactのuseEffectでgtag.jsを動的注入していたため、生のHTMLに計測タグが出てこなかったことでした。
GA4の検証クローラーはJavaScriptを実行せず生HTMLしか見ないので、SPAでdocument.head.appendChildしていると永遠に「検出されません」と言われ続けます。
ViteのtransformIndexHtmlプラグインでindex.htmlのhead内にgtag.jsを静的に焼き込んで解決します。
DevToolsの要素タブには出ているのにview-sourceには無い、という同じエラー画面で固まっている方向けに、原因の切り分け方から修正手順、検証チェックまで一通りまとめておきます。
それではやっていきましょう!
目次
どんなトラブルだったか
個人開発の在庫管理アプリ「ざいこにゃん🐾」(site-de-zaiko.shinpinoshi.com)にGA4を入れたあと、管理画面でセットアップ確認をしようとしました。
GA4管理画面 → データストリーム → ウェブサイトの詳細 →「ウェブサイトをテストする」に本番URLを入れて実行。
そこで返ってきたのが「ウェブサイトでGoogleタグが検出されませんでした」という赤字のエラーでした。
環境変数の設定や貼り付け位置に問題はなく、設定上は何も間違っていないはずなのに「タグが検出されません」と言われ続ける。
「なぜクローラーがgtagタグを読み取れないのか」本当に謎でした(–;
原因:adminの検証クローラーはJavaScriptを実行しない
色々と切り分けた結果、原因はシンプルでした。
GA4管理画面の「ウェブサイトをテストする」機能のサイト検証クローラーは、JavaScriptを実行せず、生のHTMLだけをスキャンする。
だからReactのuseEffectでマウント後にdocument.head.appendChild(script)しているとgtag.jsを見つけられない。
具体的にはこんな構造です。
- 実DOM(DevToolsの「要素」タブ)にはgtag.jsタグが存在する
- ブラウザでReactが動いた後の状態なので、
useEffectで注入したスクリプトがちゃんと入っている
- ブラウザでReactが動いた後の状態なので、
- 生HTML(
Ctrl+Uのview-source)には存在しない- サーバから返ってきた直後のHTMLにはplaceholderしか無く、JSが動かないクローラーから見たら「タグなし」
- GA4 adminのクローラーは生HTMLしか見ない
- だから「検出されません」と判定される
実際、本番URLを開いたDevToolsの要素タブには、</head>直前にgtag.jsの<script>がしっかり並んでいました。
「DOMには入っているのにadminからは検出されない」、これがいちばんデバッグを長引かせるパターンです。
view-sourceで確認:「実DOM」と「生HTML」は別物
切り分けで一番大事だったのは、ここを混同しないことでした。
確認のしかたは単純です。
本番URLをブラウザで開いた状態でCtrl+U(view-source)を押し、表示されたソースにgoogletagmanagerの文字列が含まれているかを確認してください。
ヒットしなければクローラーからは検出されません。
DevToolsの要素タブは「JSが動いた後の実DOM」、view-sourceは「サーバが返した直後の生HTML」。
動的にscriptを足す設計だと、この2つに差分が出ます。
元の実装:useEffectでgtag.jsを動的注入していた
元の実装はこんな形でした。
src/features/analytics/ga4.ts
1 | export const GA4_MEASUREMENT_ID: string = |
呼び出し側はuseEffectでマウント後に1回叩く形でした。
src/app/AppShell.tsx
1 | import { initGA4 } from "../features/analytics/ga4"; |
この設計だと、admin検証クローラーから見たときに生HTMLにgtag.jsが出てこないことになります。
解決策:ViteのtransformIndexHtmlでindex.htmlに静的埋め込み
ここからは、ViteのtransformIndexHtmlプラグインでindex.htmlにgtag.jsを焼き込むだけです。
vite.config.tsにga4HtmlPluginを追加
vite.config.ts
1 | const ga4HtmlPlugin = (measurementId: string): Plugin => ({ |
ポイントはorder: "pre"で他のプラグインより先に走らせること、それと環境変数が空のときはhtml.replace("<!--GA4-->", "")でplaceholderだけ消すこと。
これで開発用の値漏れと、本番ビルドへの残骸混入を両方防げます。
index.htmlにプレースホルダを置く
index.html
1 | <!--GA4--> |
<head>の閉じタグ直前にコメントを1つ置いておくだけ。
あとはVite側がビルド時に置換してくれます。
ga4.tsの簡素化(53行 → 14行)
静的スニペット側でwindow.dataLayer/window.gtag/gtag('config', ...)を全部やってくれるので、ランタイム側の初期化処理は不要になります。
src/features/analytics/ga4.ts
1 | export const GA4_MEASUREMENT_ID: string = |
initGA4()/ensureGtagStub()/SCRIPT_ID/initializedフラグは全部消しました。
SPAルート遷移ごとのpage_view送信は別途用意していたフックで継続するので、ランタイム側で残すのはsendPageViewだけで十分です。
initGA4()の呼び出しを撤去
initGA4()を呼んでいたuseEffectもまるごと削除します。
nonum
1 | // 削除 |
ビルド結果の確認
npm run buildしてdist/index.htmlを覗くと、gtag.jsがちゃんと静的に出てきているのが分かります。
dist/index.html
1 | <script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"></script> |
これがゴール地点です。
view-sourceでgoogletagmanagerをgrepしてヒットすれば、admin側からも検出されるはずです。
設計判断の根拠
いくつかの判断ポイントも残しておきます。
send_page_view: falseを静的スニペット側で指定するgtag('config', ...)は初回に自動でpage_viewを送るが、SPAではルーティング途中に誤送信されることがある。自前送信と二重発火するので、自動送信は最初から止める
anonymize_ip: trueも静的スニペット側で指定する- あとから
gtag('config', ..., { anonymize_ip: true })してもタイミング次第で初回ヒットに間に合わない。プライバシー保護の保険として最初から付けておく
- あとから
- Viteプラグイン(
transformIndexHtml)パターンを選ぶ- SSR/SSGを導入せずに済む(小型SPAには重すぎる)。シンプルな文字列置換だけで完結するので保守コストも低い
initGA4()は併用ではなく完全撤去にする- 静的スニペットが
dataLayer/gtag/configを全部やってくれるので、ランタイム側に再度書くと完全に重複する。二重ロード対策コードが増えるだけで利点なし
- 静的スニペットが
「静的に置く側」と「動的に動かす側」の境界をはっきり分けるのが今回のキモでした。
検証手順
最後に、修正後に通したチェックを並べておきます。
- ローカルビルド検証
npm run buildしてdist/index.htmlの<head>にgtag.jsスニペットが入っていることを確認
- view-source検証(今回の本質)
- 本番デプロイ後、ブラウザでCtrl+Uを押して生HTMLに
googletagmanager.com/gtag/jsが含まれているか確認
- 本番デプロイ後、ブラウザでCtrl+Uを押して生HTMLに
- DevTools検証
- Networkタブで
googletagmanager.com/gtag/js?id=G-...が200で返っている - 画面遷移時に
google-analytics.com/g/collectが飛んでいる - Consoleで
window.gtagがfunction、window.dataLayerが配列
- Networkタブで
- GA4 admin検証
- 「ウェブサイトをテストする」を再実行 → 「Googleタグが検出されました」に変わる
- リアルタイムレポートに自分のアクセスが乗る
- 既存テスト
sendPageViewをモックしている前提なら、initGA4撤去の影響なくpassする
ここまで通れば、admin側もリアルタイムレポート側もちゃんと揃った状態になります。
教訓:クローラー検証系はJSを実行しない前提で組まれている
今回いちばん身に染みた学びはこれでした。
GA4 adminだけでなく、SEO監査ツールの一部、SNSのOGPプレビューBot、構造化データの自動検証など、「自動検証クローラー系」は基本的にJSを実行しない前提で作られている。
だから計測タグ・OGメタ・構造化データといった「クローラーに見せたい要素」は、CSRフレームワークを使っていても生のindex.htmlに静的に出現させるのが基本になる。
逆にいうと、Reactでも何でも好きに書いていいけれど、「クローラーに見つけてもらわないと意味がない部分」だけは別ラインで扱う、という感覚で良さそうです。
Viteで実現するなら、transformIndexHtmlプラグインでプレースホルダ置換するのが素直な答えです。
少し癖が強い仕組みですが、ちょっとした気遣いで対応できるので大丈夫そうですね!!
まとめ
いかがでしたでしょうか?
実際には半日くらい悩んで導き出した答えなので、誰かの助けになったらうれしいです!
同じ症状で詰まっている方は、まず以下を試してみてください。
- 本番URLをCtrl+Uで開いて
googletagmanagerをgrepする- ヒットしなければ、いくらDOM側で動いていてもadminからは「検出されません」と言われ続ける
useEffectで動的注入している場合は、ビルドツール側でindex.htmlに焼き込み直す- Viteなら
transformIndexHtmlプラグインでプレースホルダ置換するのが素直
- Viteなら
send_page_view: falseにして、SPAでは自前でpage_viewを送る- 二重発火と「/のpage_view誤送信」を両方防げる
ちなみにこの記事の元になったトラブルが発生したのは、個人開発の在庫管理アプリ「ざいこにゃん🐾」です。
複数フリマ・ECの在庫と粗利を一元管理するPWAで、登録不要・データは端末内のみに保存しています。
よかったら覗いてみてください(^^
ざいこにゃん🐾site-de-zaiko.shinpinoshi.com
以上となります。
クローラーは自分で制御できない部分なので、色々と苦戦を仕入れられますよね…
それではお疲れさまでした!




