GTM の noscript iframe を React で書いたら GSC 認証が通らない件
2026-04-28
Google Tag Manager(GTM)を Next.js 静的ビルドに導入して、Search Console(GSC)の所有権認証を GTM 経由で通そうとしたら、「Google Tag Manager が見つかりません」と弾かれ続ける事案にぶつかった。
GTM 公式ドキュメントは 2 箇所への配置を要求する:
<head>内に GTM の JavaScript スニペット<body>直後に noscript iframe(JS 無効環境向け)
ハマったのは 2 番目の noscript iframe。React コンポーネントとして書くと、こうなる。
// 失敗例: app/layout.tsx
export default function RootLayout({ children }) {
return (
<html>
<body>
<noscript>
<iframe
src="https://www.googletagmanager.com/ns.html?id=GTM-XXXX"
height="0"
width="0"
style={{ display: "none", visibility: "hidden" }}
/>
</noscript>
{children}
</body>
</html>
);
}
これでビルドすると、出力 HTML はこうなる。
<body>
<!--$--><noscript>...</noscript><!--/$-->
<div id="__next">...</div>
</body>
問題は React が hydration マーカー(<!--$-->)を noscript の前に挿入すること。GTM の検証スクリプトは正規表現で「<body> の直後」を厳密に見ているため、間にコメントノードが挟まると 「直後」と判定されず認証失敗 になる。
さらに output: "export" で静的ビルドすると、JSX で書いた hidden div は dehydrated state を保持するための noise が周囲にまとわり付き、シンプルな iframe ですら GTM パーサが期待する位置に置かれない。
解決 は build 後の post-process で sed 相当の文字列置換。React に書かせるのを諦めて、ビルド完了後に全 HTML ファイルを舐めて <body> の直後に物理的に noscript iframe を埋め込む。
// scripts/inject-gtm-noscript.mjs
import fs from "node:fs/promises";
import path from "node:path";
const OUT_DIR = "out";
const GTM_NOSCRIPT =
'<noscript><iframe src="https://www.googletagmanager.com/ns.html?id=GTM-XXXX" height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>';
async function walkHtml(dir) {
const out = [];
async function walk(p) {
const entries = await fs.readdir(p, { withFileTypes: true });
for (const e of entries) {
const full = path.join(p, e.name);
if (e.isDirectory()) await walk(full);
else if (e.isFile() && full.endsWith(".html")) out.push(full);
}
}
await walk(dir);
return out;
}
const htmlFiles = await walkHtml(OUT_DIR);
for (const f of htmlFiles) {
let html = await fs.readFile(f, "utf8");
// 既存の React 由来 noscript があれば除去(冪等性)
html = html.replace(
/<noscript><iframe src="https:\/\/www\.googletagmanager\.com\/ns\.html[^"]*"[^>]*><\/iframe><\/noscript>/g,
""
);
// <body ...> 直後に物理挿入
const newHtml = html.replace(/(<body[^>]*>)/, `$1${GTM_NOSCRIPT}`);
if (newHtml !== html) await fs.writeFile(f, newHtml, "utf8");
}
ポイントは 3 つ:
- 冪等性: 既存の noscript を除去してから挿入。スクリプトを 2 回走らせても重複しない
- 正規表現で
<body ...>直後: 属性付き body にも対応(<body class="...">など) - 全ファイル走査:
out/配下のすべての.htmlに対して実行(static export ではページごとに個別 HTML)
GSC の所有権認証は 正規表現の厳密さに依存 している。React の hydration コメントが間に入るだけで通らないので、JS で生成する経路は捨てて静的注入に倒すのが結局速い。
この手の「フレームワークが噛む noise」は Static Export を採用したときに必ず一度はぶつかる。GTM・Tag Manager 系・サードパーティスクリプトはどれも post-process injection を最初から組み込んでおくのが安全だと学んだ。