Next.js に GA4 + Cookie 同意バナーを埋め込む

## はじめに
GA4 を利用するため、Cookie Consent Banner を自前で埋め込んだ。本来？、Cookie を利用する場合、自前での対応はほぼ不可能であるため、Consent Management Platform（CMP）を利用すべきであるが、それだと JavaScript 埋め込むだけなので、今回は自前での実装を行う。

![Screenshot 2024-07-03 at 13.49.13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245908/e34664a8-cc32-2c25-72c9-acd4c1fec3a9.png)


CMP を利用するか否かは、

[”訴えられない”ように Google Analytics を使う方法を考える](https://qiita.com/kurab/items/234e4efa0f358716a3ea)

を参考に、慎重に検討する必要がある。

## 手順

Cookie Notice 等は既に用意されているものとする。

1. GA4 のスクリプトを default では送信しないように埋め込む
1. Cookie Conset Banner を埋め込み、同意状態を LocalStorage に保存する
1. 同意が取れたら送信する

今回は、安全第一で、同意が取れなければ送らないという方針にしているが、対応する国やエリアによっては、拒否されるまでは取って良かったりなどあるので、どこ向けかで方針は変わる。いずれにしても、Cookie 同意の対応は、今回紹介する Banner 埋め込みが2割、ポリシーが8割（こっちが無理）という感じだ。

Cookie 利用に同意するユーザは 3 割程度らしいので、なんとかデータをたくさん取りたいマーケター的には、拒否されるまで取って良いところでは、取って欲しいと言うに違いない。

## GA4 のスクリプトを埋め込む

Next.js には、3rd Party 用のライブラリがあり、ただ埋め込むだけなら簡単だ。

```ts
import { GoogleAnalytics } from '@next/third-parties/google'

export default function Page() {
  return <GoogleAnalytics gaId="G-XYZ" />
}
```

`@next/third-paties` では、Google Tag Manager, Google Analytics, Google Maps, YouTube
 などが上記のようなお手軽さで利用できる。ただし、このライブラリは現在 Experimental であり、今回やりたいことはできないので、残念ながら使えない。今後に期待。詳細は、[こちら](https://langxlang.com/ja/javascript/next.js/v14/02-app/01-building-your-application/06-optimizing/12-third-party-libraries) を参照。

 埋め込むべきタグは、基本のタグ、

```html
<!-- Google tag (gtag.js) -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-S84EEZJCTF"></script>
<script>
  window.dataLayer = window.dataLayer || [];
  function gtag(){dataLayer.push(arguments);}
  gtag('js', new Date());

  gtag('config', 'G-XXXX');
</script>
```

これに、同意ステータスのデフォルトを追加したものになる。

```html
gtag('consent', 'default', {
  'ad_storage': 'denied',
  'ad_user_data': 'denied',
  'ad_personalization': 'denied',
  'analytics_storage': 'denied'
});
```

`region` オプションなどもあるので、エリアごとのデフォルト値が設定可能だ。詳細は、[同意設定を管理する（ウェブ）](https://developers.google.com/tag-platform/security/guides/consent?hl=ja) を参照。できることは日々変わっているので、定期的に確認を。

Next.js では、サードパーティのスクリプトをロードしたり、インラインスクリプトを読み込むには、`next/script` を使う。`strategy` オプションで、ロードのタイミングを調整できる。詳細は以下を参照。

- [Script Optimization](https://langxlang.com/ja/javascript/next.js/v14/02-app/01-building-your-application/06-optimizing/05-scripts)
- [\<Script\>](https://langxlang.com/ja/javascript/next.js/v14/02-app/02-api-reference/01-components/script#strategy)

以上を踏まえ、GA4 コンポーネントは以下のようになる

```ts:src/app/components/GA4.tsx
'use client';

import Script from 'next/script';

type Props = {
  gaId: string;
};

export default function GA4({ gaId }: Props) {
  return (
    <>
      <Script
        strategy="afterInteractive"
        src={`https://www.googletagmanager.com/gtag/js?id=${gaId}`}
      />
      <Script
        id="google-analytics"
        strategy="afterInteractive"
        dangerouslySetInnerHTML={{
          __html: `
                window.dataLayer = window.dataLayer || [];
                function gtag(){dataLayer.push(arguments);}
                gtag('js', new Date());

                gtag('consent', 'default', {
                    'ad_storage': 'denied',
                    'ad_user_data': 'denied',
                    'ad_personalization': 'denied',
                    'analytics_storage': 'denied'
                });
                gtag('config', '${gaId}', {
                    page_path: window.location.pathname,
                })
                `,
        }}
      />
    </>
  );
}
```

これを、今回は全てのページに埋め込みたいので、RootLayout に埋め込む。読み込みは、`strategy` オプションで調整されるので、どこに配置するかは気にしなくて良い。`afterInteractive` (default) は、Next.js のコードの邪魔をしない。

## Cookie Consent Banner を埋め込む

まず、同意状態を LocalStorage に保存するので、get, set, remove の helper を作っておく。

```src/app/lib/storageHelper.ts
import 'client-only';

export function getLocalStorage(key: string, defaultValud: any) {
  const stickyValue = localStorage.getItem(key);

  return stickyValue !== null && stickyValue !== 'undefined'
    ? JSON.parse(stickyValue)
    : defaultValud;
}

export function setLocalStorage(key: string, value: any) {
  localStorage.setItem(key, JSON.stringify(value));
}

export function removeLocalStorage(key: string) {
  localStorage.removeItem(key);
}
```

`client-only` パッケージはインストールが必要。

CookieConsentBanner コンポーネントのスケルトンは、以下のようになる

```src/app/components/CookieConsentBanner.tsx
'use client'

export default function CookieConsentBanner() {
  // ユーザの同意状態
  const [consentMode, setConsentMode] = useState<ConsentModeType | null>(null);

  // LocalStorage から同意状態をロード
  useEffect(() => {...}, [setConsentMode]);
  // 同意状態が変わったら、update イベントを送信
  useEffect(() => {...}, [ConsentMode]);
  // ボタンが押されたら、LocalStorage, State を更新
  const setCookieConsent = (value: boolean) => {...}

  return <div>...</div>
```

### ユーザの同意状態

```ts
  // ユーザの同意状態
  const [consentMode, setConsentMode] = useState<ConsentModeType | null>(null);
```

```ts
type ConsentModeType = {
  ad_storage: 'granted' | 'denied' | undefined;
  ad_user_data: 'granted' | 'denied' | undefined;
  ad_personalization: 'granted' | 'denied' | undefined;
  analytics_storage: 'granted' | 'denied' | undefined;
};
```

同意も拒否もしていない状態を `null` とする。


### 表示部分

```tsx
  return (
    <div
      className={clsx(styles.cookieConsentBanner, {
        [styles.cookieConsentBannerHidden]: consentMode !== null,
      })}
    >
      <p>
        当社サイトでは、Cookieを使用しています。各規約をご確認の上ご利用ください: <br />
        <Link href="/cookies">Cookie Policy</Link>,
        <Link href="/privacy">Privacy Policy</Link> および
        <Link href="/terms">Terms of Use</Link>
      </p>
      <div className={styles.buttons}>
        <button
          className={styles.cookieDecline}
          onClick={() => setCookieConsent(false)}
        >
          拒否する
        </button>
        <button
          className={styles.cookieAccepted}
          onClick={() => setCookieConsent(true)}
        >
          Cookie を受け入れる
        </button>
      </div>
    </div>
  )
}
 ```

選択同意は今回はなしにした。必要な場合は、選択させるバルーンなどを設置したりなどが必要。同意または拒否済みの場合は、表示しないように style を調整した。

### 初期状態のロード

 ```tsx
  // LocalStorage から同意状態をロード
  useEffect(() => {
    const storedConsentMode = getLocalStorage('consentMode', null);
    setConsentMode(storedConsentMode);
  }, [setConsentMode]);
 ```

ここでは LocalStorage から読み込んだものを State に入れているだけだが、ポリシーを更新した場合や、同意から一定期間経過した場合や、LocalStorage に保存されている内容が不適切な場合などに、同意を取り直すようにしたい場合は、その内容をここに書く。同意したポリシーのバージョンや、同意日時などは、LocalStorage に一緒に入れておけば良い。

### LocalStorage/State の更新

```tsx
  // ボタンが押されたら、LocalStorage, State を更新
  const setCookieConsent = (value: boolean) => {
    const newValue = value === true ? 'granted' : 'denied';
    const newConsentMode: ConsentModeType = {
      ad_storage: newValue,
      ad_user_data: newValue,
      ad_personalization: newValue,
      analytics_storage: newValue,
    };
    setLocalStorage('consentMode', newConsentMode);
    setConsentMode(newConsentMode);
  }
```

前述の通り、選択同意はしないので、全同意か全拒否のどちらかになっている。だったら、LocalStorage にも `true/false` を入れておけば良いのでは？と思うが、その通りである。その通りであるが、将来的に選択同意のロジックを入れるかも知れないし、ポリシー同意のバージョン、日時も入れておきたいので、このようにしてある。

### update イベントを送信

```tsx
  // 同意状態が変わったら、update イベントを送信
  useEffect(() => {
    if (consentMode !== null) {
      window.gtag('consent', 'update', {
        ad_storage: consentMode.ad_storage,
        ad_user_data: consentMode.ad_user_data,
        ad_personalization: consentMode.ad_personalization,
        analytics_storage: consentMode.analytics_storage,
      });
    }
  }, [ConsentMode]);
```

これで、同意していればデータ取得が開始し、拒否していればデータ取得は開始しない。同意も拒否もしていなければ、呼び出されない（ので取得されない）。


RootLayout に埋め込む。

## まとめ

今回追加・修正したファイルは以下の通り。

```text
src/app/
 ├── lib
 │   └── storageHelper.ts
 ├── components
 │   ├── CookieConsentBanner.tsx
 │   └── GA4.tsx
 └── layout.tsx
```

実際にこの方法で埋め込んだ [Lang x Lang](https://langxlang.com) では、

- 同意から一定期間が経過
- ポリシーの変更があった
- LocalStorage に保存された内容が不正

な場合に、同意を取り直すようにしている（ここで、`removeLocalStorage` を使う）。ただし、近い将来 CMP を利用する予定なので、これは一時的な対応だ。実際の動作は Lang x Lang で確認できる。Local Storage の中身を確認してみると良い。

https://langxlang.com

繰り返しになるが、Cookie Consent Management は自前での対応はほぼ不可能なので、今回紹介した方法は、真っ黒をグレーに変える程度の対応である。


なお、実際にこれで運用してみて、データはきちんと取れてはいるが、同意率3割程度というのは、まぁだいたいそんなものかなという感じだ。うざすぎるバナーで離脱説も納得だが、ユーザにとって Essential Cookie 以外、受け入れるメリットがない。ユーザはちゃんと考えてるということですね。


（おしまい）