Next.jsとGTMで読了率を計測するのに苦労した話

この記事は、[All About Group（株式会社オールアバウト） Advent Calendar 2022](https://qiita.com/advent-calendar/2022/allabout) 24日目の記事です。

## はじめに

この記事では、Next.jsとGTMを使ってページの読了率を計測しようとした際に苦労したポイントと、最終的に採用した方法をまとめます。

Next.jsや読了率に限らず、SPAベースのサービスにGTMを組み込む際には参考になる内容だと思うので、最後まで読んでいただけると幸いです。

では早速始めていきます。

## 目次
[0.バージョン情報](#バージョン情報)
[1.コンテキストの共有](#コンテキストの共有)
[2.結論に達するまでに試したこと](#結論に達するまでに試したこと)
[3.発生した問題について](#発生した問題について)
[4.問題が発生した原因について](#問題が発生した原因について)
[5.カスタムイベントを利用する](#カスタムイベントを利用する)
[6.おわりに](#おわりに)

## バージョン情報
- Next.js:12.3.1
- React:18.2.0
- Node.js:18.10.0

## コンテキストの共有

最初にコンテキスト（前提）の共有をしておきます。

このタスクを通して達成したいゴールは以下の通りです。

``Next.jsとGTMを連携させて、ページを特定の位置までスクロールした際にGTMのタグを発火させたい。なおかつ、その発火状況をGoogle Analyticsに送信できるようにしたい``

さらに、以下のいずれの状況においても、画面スクロールをした際にタグを発火させるようにしたいです。

- 初回ページ表示時
- [next/link](https://nextjs.org/docs/api-reference/next/link)を使用したページ遷移時

## 結論に達するまでに試したこと
メインで試したのは、[GTMのトリガー](https://support.google.com/tagmanager/answer/7679316?hl=ja)の一種である、[要素の表示トリガー](https://support.google.com/tagmanager/answer/7679410?hl=ja)を使った方法です。

以下で詳細をお話ししていきます。

まずは、Next.jsとGTMを連携させるため、以下のコードを_app.tsxおよび_document.tsxに設置しました。

GTM_IDにはGTMプロジェクトのIDが入ります。

```typescript:_app.tsx
/* eslint-disable react/jsx-props-no-spreading */
import { Global } from "@emotion/react";
import type { AppProps } from "next/app";
import Script from "next/script";
import { GTM_ID } from "../lib/gtm";
import { destyle } from "../styles/destyle";
import { global } from "../styles/global";

function MyApp({ Component, pageProps }: AppProps) {
  return (
    <>
      <Script
        id="gtag-base"
        strategy="afterInteractive"
        dangerouslySetInnerHTML={{
          __html: `
            (function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
            new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
            j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
            'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
            })(window,document,'script','dataLayer','${GTM_ID}');
          `,
        }}
      />
      <Global styles={[destyle, global]} />
      <Component {...pageProps} />
    </>
  );
}

export default MyApp;
```

```typescript:_document.tsx
import { Html, Head, Main, NextScript } from "next/document";
import { GTM_ID } from "../lib/gtm";

export default function Document() {
  return (
    <Html>
      <Head />
      <body>
        <noscript>
          <iframe
            title="gtm"
            src={`https://www.googletagmanager.com/ns.html?id=${GTM_ID}`}
            height="0"
            width="0"
            style={{ display: "none", visibility: "hidden" }}
          />
        </noscript>
        <Main />
        <NextScript />
      </body>
    </Html>
  );
}
```


この辺りの実装は、[こちら](https://github.com/vercel/next.js/tree/canary/examples/with-google-tag-manager)のNext.jsのサンプルが参考になります。

そして、要素の表示トリガーのターゲットとして、Footerのコンポーネントに以下のようにidを設定しました。

idの部分には記事IDが入るようにし、別の記事ページに遷移した際にidも切り替わるようにしました。

```typescript:footer.tsx
return (
    <div id={`gtm-article-end-${id}`} css={footer}>
      {/* sample */}
    </div>
);
```

そして、GTM側で以下のように要素の表示トリガーを設定し、指定したIDの要素が画面上に表示されたらタグが発火するようにしました。

{{記事IDテスト}} にはGTMのデータレイヤー変数を設定し、記事ごとに可変になるようにしています。

![スクリーンショット 2022-12-19 10.21.04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/913459/efb684eb-b815-8318-a9ba-6d82abdbfcb3.png)

そして、タグを以下のように設定し、トリガーが要素を検知する度にscroll_testというイベント名でGAに通知が飛ぶようにしました。
![スクリーンショット 2022-12-19 10.22.54.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/913459/ea537e30-7db5-cbe1-622b-b893554d6b29.png)

この状態で記事ページを開くと、初回ページ表示時にはタグが発火し、GA側にも通知が送られます。

しかし、next/link経由で画面遷移した場合は、指定した要素を表示してもタグは発火しませんでした。

## 発生した問題について
このように、要素の表示トリガーで読了率の計測を実現しようとすると、初回ページ表示時以外はタグが正しく発火せず、GAにも通知されません。

この問題を解消するため、ページ遷移時にデータレイヤー変数（記事IDテスト）の値を直接書き換えたり、タグ発火をリセットする方法も調査・検証しましたが、解決に至ることはできませんでした。

## 問題が発生した原因について

原因については、以下の「要素ID」が、変数の部分（記事IDテスト）を一つの値として捉えているため、記事IDテストの具体的な値が切り変わっても同じ要素とみなされてしまうためと考えられます。
![スクリーンショット 2022-12-19 10.21.04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/913459/efb684eb-b815-8318-a9ba-6d82abdbfcb3.png)

例えばSPAでページ遷移して要素IDがgtm-article-end-1からgtm-article-end-2に変わったとしても、gtm-article-end-1とgtm-article-end-2は同一視されるため、gtm-article-end-1で既にタグが発火している場合はgtm-article-end-2では発火しないのです。


このように、GTMの変数は、その変数自体で一つの値と捉えられており、一度発火してしまったら、変数の中身が変わったとしても再度発火することはありません。

そのため、next/linkを使ったサイトで「要素の表示トリガー」を使って読了率を計測するのは難しいとの結論になりました。

## カスタムイベントを利用する

様々な方法を試した上で最終的にたどり着いたのが、[カスタムイベントトリガー](https://support.google.com/tagmanager/answer/7679219?hl=ja)を使用する方法です。

カスタムイベントトリガーとは、発火タイミングをアプリ側で制御できるトリガーです。

指定した要素が表示されたことの検知はアプリ（Next.js）側で行い、都度カスタムイベントを送信することで、GTM側で特定の要素が表示されたことを検知できるようにしました。

以下に、実装例を示します。

まず最初に、GTM側でカスタムイベントトリガーを作成しました。
![スクリーンショット 2022-12-19 10.56.30.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/913459/5f58ddbc-eb73-1208-2226-878ad69a6142.png)

そして、作成したトリガーを設定したタグを作成しました。
![スクリーンショット 2022-12-19 10.57.39.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/913459/82e0c59c-ac20-c25b-9964-fa50ecc37c16.png)

これにより、イベント名（article-end）のカスタムイベントが発火した場合にGA側にarticle_end_testの名前で通知が送られるようになります。

次に、アプリ側で特定の要素が表示された場合にカスタムイベントを送信できるようにしました。

```typescript:footer.tsx
const targetId = "gtm-article-end";
useEffect(() => {
  const targetElement = document.getElementById(targetId);
  if (!targetElement) {
    return;
  }

  const articleEndObserver = new IntersectionObserver((entries, observer) => {
    entries.forEach((entry) => {
      if (entry.isIntersecting) {
        window.dataLayer = window.dataLayer || [];
        window.dataLayer.push({ event: "article-end" });

        observer.unobserve(targetElement);
      }
    });
  });

  articleEndObserver.observe(targetElement);
});

return (
  <div id={`${targetId}`} css={footer}>
    {/* sample */}
  </div>
);
```


簡単にコードの説明をします。

まず、特定の要素が表示されたことを検知する処理とカスタムイベントを送信する処理は、useEffect内で記述しました。

useEffectは[React Hooks](https://ja.reactjs.org/docs/hooks-intro.html)の一つです。

useEffectはコンポーネントをレンダーした後に呼ばれるため、DOM操作等の処理に適しています。
今回は、要素を検知する際の目印としてDOM要素を取得する必要があるためuseEffectを使用しました。

また、特定の要素が表示されたことの検知には、JavaScriptの[交差オブザーバー API](https://developer.mozilla.org/ja/docs/Web/API/Intersection_Observer_API)を利用しました。

```new IntersectionObserver```で交差オブザーバー APIのインスタンスを作成し、引数のコールバック関数で表示を検知した際の処理を記述します。
isIntersectingで指定した要素がビューポート（画面）と交差しているかを判定できます。

今回は、指定したid（gtm-article-end）の要素が画面に表示された際に、GTMのデータレイヤーに対してカスタムイベント（article-end）を送信するようにしました。


この実装により、next/linkでページが切り替わったとしても、GTM側で特定の要素が表示されたことを検知できるようになりました。



## おわりに
現状は、Next.jsのnext/linkのようにSPA的に動作するサイトとGTMを連携して動かすには、カスタムイベントを使うのが正攻法だと思います。（他に良い方法があればぜひ教えてください。。）

とはいえ、今回の読了率の計測のようなカスタムイベントを使った処理をアプリ側で実装しないといけないので、GTMの旨味が薄れるのは間違いありません。

早くGTMがSPAに対応してくれることを願うばかりです。


今回は以上となります。
最後まで読んでいただきありがとうございました。

この記事がNext.jsとGTMを連携して動作させる上で、少しでも参考になっていれば幸いです。