Next.jsアプリにおけるGoogle Consent Mode v2対応と、Cookie同意バナーからGTM/Clarityタグの動的なロード制御フローを示しており、hydration mismatchの解決策も描かれています。

はじめに

Cookie同意バナーを実装したはずなのに、計測タグ自体は同意前からしっかり読み込まれている、というケースは意外と見かけます。バナーのUIだけを作って満足してしまい、GTMやアクセス解析スクリプトの「読み込みタイミング」までは手が回っていないパターンです。

Googleが求めるConsent Mode v2への対応では、単に同意状態をCookieに保存するだけでなく、計測スクリプト本体をロードするより前に同意の初期状態(デフォルト値)をdataLayerへ積んでおく必要があります。この記事では、Next.jsアプリでGoogle Tag Manager(GTM)とMicrosoft Clarityを、同意状態に応じて動的にゲーティングする実装パターンを、実際にハマったポイントとあわせて整理します。

こんな人におすすめ

  • Next.jsアプリにGTMやMicrosoft Clarityなどの計測タグを導入していて、同意バナーとの連携に悩んでいる方
  • Google Consent Mode v2への対応を検討しているフロントエンドエンジニアの方
  • SSRとクライアントで状態がずれるhydration mismatchに一度は悩まされたことがある方
  • 同意を「撤回」したときの挙動をどう設計すべきか迷っている方
  • 同意フローをE2Eテストにどう組み込むか知りたい方

defaultコマンドは「本体ロードより先に」積む

Consent Mode v2の前提条件は、GTM本体やClarityのスクリプトを実際にロードするより先に、consentdefaultコマンドをdataLayerへpushしておくことです。同意バナーコンポーネントを常時マウントしておき、そのマウント時に初期化関数を呼ぶことで、ユーザーがまだ同意を選択していない間もdefaultコマンドだけは必ず発行される構成にしています。

export function initializeConsentMode(): void {
  if (typeof window === 'undefined' || typeof document === 'undefined') return;
  if (initialized) {
    ensureGtag();
    return;
  }

  initialized = true;
  ensureGtag();
  const state = getConsentState();
  pushConsentCommand('default', DEFAULT_CONSENT);

  if (state === 'granted') {
    applyConsentState(state);
  }
}

初期化を一度きりのフラグで守りつつ、すでに許可済みの状態であればそのまま続けてスクリプトロードまで進めます。順序さえ守れば、あとの処理はシンプルです。

SSRとクライアントの状態差はuseSyncExternalStoreで吸収する

同意状態はCookieというクライアント側だけの情報なので、サーバーでレンダリングしたHTMLとクライアントの初回描画で状態が食い違うと、hydration mismatchが発生します。ここではuseSyncExternalStoreの第3引数getServerSnapshotを常に固定値(未選択)で返すことで、サーバー生成HTMLとクライアント初回描画を強制的に一致させています。

const getServerSnapshot = (): ConsentState => 'pending';

export function useConsent(): ConsentState {
  const consentState = useSyncExternalStore(
    subscribeToConsentChanges,
    getConsentState,
    getServerSnapshot
  );

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

  return consentState;
}

実際の同意状態への反映は、マウント後のuseEffectでCookieを読みにいく形になります。SSRを使う構成でCookie依存の状態を扱うときは、この「サーバー側は固定のプレースホルダーを返す」という考え方が汎用的に使えます。

スクリプトの二重挿入はdata-*属性の存在チェックで十分

GTMのロード処理はReactのコンポーネントツリー外で行う都合上、document.createElement('script')による生DOM操作になります。この場合、Reactのkeyやrefは使えないため、二重挿入の防止はdata-*属性でマーキングしたscriptタグの有無をクエリで確認する、という素朴な方法で対応しています。

const loadGtm = (): void => {
  const containerId = process.env.NEXT_PUBLIC_GTM_ID;
  if (!containerId) return;
  if (document.querySelector('script[data-analytics="gtm"]')) return;

  ensureDataLayer().push({ 'gtm.start': new Date().getTime(), event: 'gtm.js' });

  const script = document.createElement('script');
  script.async = true;
  script.dataset.analytics = 'gtm';
  script.src = `https://www.googletagmanager.com/gtm.js?id=${encodeURIComponent(containerId)}`;
  document.head.appendChild(script);
};

宣言的な条件レンダリングでも似たことはできますが、拒否から許可への切り替わった「その瞬間」にだけロードしたい、というタイミング制御は生DOM操作の方が扱いやすいというのが実装上の判断です。

つまづきやすいポイント

  • 同意状態(未選択/許可/拒否)はあくまでファーストパーティCookieに留め、GA4のdataLayerなど外部送信先には一切送らない設計にしています。同意情報自体が取り扱いに注意が必要な情報になり得るためです。
  • 同意を「拒否」に戻しても、一度挿入したscriptタグをその場でDOMから除去する必要はありません。除去のタイミングはページ再読み込み後の初回ロード時点に限定する方が、内部状態との整合が崩れにくくなります。仕様として明記しておかないと、後からバグと誤解されやすい挙動です。
  • E2Eテストのデフォルトstorage stateを「未選択」のままにすると、同意バナー機能の追加が既存の全E2Eスイートに影響してしまいます。デフォルトは許可済みの状態をseedしておき、同意フロー自体を検証するテストだけ空のstorage stateから明示的に始める、という切り分けが有効でした。
  • CI環境のビルドにダミーの計測IDを環境変数として焼き込んでおかないと、「同意許可後に実際にscriptタグが挿入されるか」までをE2Eで検証できません。実際の計測基盤への到達性までは検証対象外と割り切るのがポイントです。

まとめ

Consent Mode v2対応の勘所は、同意状態の保存よりも「defaultコマンドをスクリプト本体より先にpushする」という順序の担保にあります。SSR環境ではgetServerSnapshotを固定値にしてhydration mismatchを避け、同意撤回時のタグ除去は即時ではなく次回ロード時に限定すると設計がシンプルになります。E2Eテストはデフォルトのstorage stateを許可済みにしておくことで、既存スイートへの影響を抑えられます。同意管理の実装で迷ったときの参考になれば幸いです。