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タグが検出されませんでした」という赤字のエラーでした。

GA4管理画面の「ウェブサイトをテストする」で「タグが検出されませんでした」と返ってくる
GA4管理画面で『ウェブサイトでGoogleタグが検出されませんでした』と表示されているスクリーンショット

環境変数の設定や貼り付け位置に問題はなく、設定上は何も間違っていないはずなのに「タグが検出されません」と言われ続ける。
「なぜクローラーがgtagタグを読み取れないのか」本当に謎でした(–;

原因:adminの検証クローラーはJavaScriptを実行しない

色々と切り分けた結果、原因はシンプルでした。

GA4管理画面の「ウェブサイトをテストする」機能のサイト検証クローラーは、JavaScriptを実行せず、生のHTMLだけをスキャンする
だからReactのuseEffectでマウント後にdocument.head.appendChild(script)しているとgtag.jsを見つけられない。

具体的にはこんな構造です。

  • 実DOM(DevToolsの「要素」タブ)にはgtag.jsタグが存在する
    • ブラウザでReactが動いた後の状態なので、useEffectで注入したスクリプトがちゃんと入っている
  • 生HTML(Ctrl+Uのview-source)には存在しない
    • サーバから返ってきた直後のHTMLにはplaceholderしか無く、JSが動かないクローラーから見たら「タグなし」
  • GA4 adminのクローラーは生HTMLしか見ない
    • だから「検出されません」と判定される

実際、本番URLを開いたDevToolsの要素タブには、</head>直前にgtag.jsの<script>がしっかり並んでいました。

DevToolsの要素タブで見ると、gtag.jsの<script>タグは</head>直前にちゃんと存在している
DevToolsの要素タブでgtag.jsの<script>タグが</head>直前に表示されている様子

「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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
export const GA4_MEASUREMENT_ID: string =
import.meta.env.VITE_GA4_MEASUREMENT_ID?.trim() ?? "";

const SCRIPT_ID = "ga4-gtag-script";
let initialized = false;

const ensureGtagStub = () => {
if (typeof window === "undefined") return;
if (!window.dataLayer) window.dataLayer = [];
if (typeof window.gtag !== "function") {
window.gtag = (...args: unknown[]) => {
window.dataLayer!.push(args);
};
}
};

export const initGA4 = (): void => {
if (initialized) return;
if (!GA4_MEASUREMENT_ID) return;
if (typeof window === "undefined" || typeof document === "undefined") return;

ensureGtagStub();

if (!document.getElementById(SCRIPT_ID)) {
const script = document.createElement("script");
script.id = SCRIPT_ID;
script.async = true;
script.src = `https://www.googletagmanager.com/gtag/js?id=${GA4_MEASUREMENT_ID}`;
document.head.appendChild(script);
}

window.gtag!("js", new Date());
window.gtag!("config", GA4_MEASUREMENT_ID, {
send_page_view: false,
anonymize_ip: true,
});

initialized = true;
};

呼び出し側はuseEffectでマウント後に1回叩く形でした。

src/app/AppShell.tsx

1
2
3
4
5
import { initGA4 } from "../features/analytics/ga4";

useEffect(() => {
initGA4();
}, []);

この設計だと、admin検証クローラーから見たときに生HTMLにgtag.jsが出てこないことになります。

解決策:ViteのtransformIndexHtmlでindex.htmlに静的埋め込み

ここからは、ViteのtransformIndexHtmlプラグインでindex.htmlにgtag.jsを焼き込むだけです。

vite.config.tsにga4HtmlPluginを追加

vite.config.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
const ga4HtmlPlugin = (measurementId: string): Plugin => ({
name: "site-de-zaiko-ga4",
transformIndexHtml: {
order: "pre",
handler(html) {
const trimmed = measurementId.trim();
if (!trimmed) return html.replace("<!--GA4-->", "");
const snippet = `<script async src="https://www.googletagmanager.com/gtag/js?id=${trimmed}"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '${trimmed}', { send_page_view: false, anonymize_ip: true });
</script>`;
return html.replace("<!--GA4-->", snippet);
},
},
});

export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), "");
const ga4MeasurementId = env.VITE_GA4_MEASUREMENT_ID ?? "";
return {
plugins: [
react(),
ga4HtmlPlugin(ga4MeasurementId),
// ... VitePWA など
],
};
});

ポイントはorder: "pre"で他のプラグインより先に走らせること、それと環境変数が空のときはhtml.replace("<!--GA4-->", "")でplaceholderだけ消すこと。
これで開発用の値漏れと、本番ビルドへの残骸混入を両方防げます。

index.htmlにプレースホルダを置く

index.html

1
2
  <!--GA4-->
</head>

<head>の閉じタグ直前にコメントを1つ置いておくだけ。
あとはVite側がビルド時に置換してくれます。

ga4.tsの簡素化(53行 → 14行)

静的スニペット側でwindow.dataLayer/window.gtag/gtag('config', ...)を全部やってくれるので、ランタイム側の初期化処理は不要になります。

src/features/analytics/ga4.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
export const GA4_MEASUREMENT_ID: string =
import.meta.env.VITE_GA4_MEASUREMENT_ID?.trim() ?? "";

export const sendPageView = (path: string, title: string | undefined): void => {
if (!GA4_MEASUREMENT_ID) return;
if (typeof window === "undefined") return;
if (typeof window.gtag !== "function") return;

window.gtag("event", "page_view", {
page_path: path,
page_title: title ?? path,
page_location: window.location.origin + path,
});
};

initGA4()/ensureGtagStub()/SCRIPT_ID/initializedフラグは全部消しました。
SPAルート遷移ごとのpage_view送信は別途用意していたフックで継続するので、ランタイム側で残すのはsendPageViewだけで十分です。

initGA4()の呼び出しを撤去

initGA4()を呼んでいたuseEffectもまるごと削除します。

nonum

1
2
3
4
5
6
// 削除
import { initGA4 } from "../features/analytics/ga4";

useEffect(() => {
initGA4();
}, []);

ビルド結果の確認

npm run buildしてdist/index.htmlを覗くと、gtag.jsがちゃんと静的に出てきているのが分かります。

dist/index.html

1
2
3
4
5
6
7
8
9
  <script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-XXXXXXXXXX', { send_page_view: false, anonymize_ip: true });
</script>
<script type="module" crossorigin src="/assets/index-xxxxxxx.js"></script>
</head>

これがゴール地点です。
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が含まれているか確認
  • DevTools検証
    • Networkタブでgoogletagmanager.com/gtag/js?id=G-...が200で返っている
    • 画面遷移時にgoogle-analytics.com/g/collectが飛んでいる
    • Consoleでwindow.gtagがfunction、window.dataLayerが配列
  • 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プラグインでプレースホルダ置換するのが素直
  • send_page_view: falseにして、SPAでは自前でpage_viewを送る
    • 二重発火と「/のpage_view誤送信」を両方防げる

ちなみにこの記事の元になったトラブルが発生したのは、個人開発の在庫管理アプリ「ざいこにゃん🐾」です。
複数フリマ・ECの在庫と粗利を一元管理するPWAで、登録不要・データは端末内のみに保存しています。
よかったら覗いてみてください(^^
ざいこにゃん🐾site-de-zaiko.shinpinoshi.com

以上となります。
クローラーは自分で制御できない部分なので、色々と苦戦を仕入れられますよね…
それではお疲れさまでした!