タグ: Next.js

  • 通ったビルドが、何も直していなかった

    通ったビルドが、何も直していなかった

    ビルドが通った。テストも落ちていない。デプロイも成功した。

    それでも、直したかったものは1つも直っていませんでした。

    この記事で扱うこと

    変更が丸ごと消えた状態でコミットが成功してしまった話と、それがなぜ「成功」に見えたのか。そして、こういう失敗を見つけるための確認が「動くかどうか」ではなく「入っているかどうか」だという話です。

    やろうとしていたこと

    2つの修正を、1回の作業でまとめる予定でした。

    直したいもの 理由
    記事一覧の取得上限(20→100) 公開記事がちょうど20本。次の1本から漏れ始める
    構造化データの日時をGMTに 時差の情報が無い時刻を、機械に渡していた

    どちらもGraphQLのクエリを変える作業です。このプロジェクトはクエリから型を自動生成しているので、クエリを変えたら型を作り直す必要があります。作り直しにはCMSへの接続が要るため、手順はこうなりました。

    予定していた手順
    1. ソースを5ファイル書き換える
    2. npm run codegen   (型を作り直す)
    3. npm run build     (手元で通ることを確認)
    4. commit → push

    全部が「成功」した

    実行結果は、どこにも問題がありませんでした。

    codegenは通りました。ビルドも通りました。コミットもpushも成功し、Vercelのデプロイも緑になりました。本番サイトは変わらず表示されています。

    ところが本番の構造化データを見ると、日時が変わっていません。

    期待していたもの / 実際
    期待  "datePublished": "2026-08-22T04:20:00Z"
    実際  "datePublished": "2026-08-22T13:20:00"

    デプロイの遅れかと思い、キャッシュを避けて何度か取り直しました。変わりません。

    コミットの中身を見た

    ここでようやく、コミットに何が入っているかを確認しました。

    git show –stat
    frontend/src/gql/gql.ts     | 5 +++++
    frontend/src/gql/graphql.ts | 8 +++++++-
    2 files changed, 12 insertions(+), 1 deletion(-)

    2ファイル。しかも両方とも、自動生成されたファイルです。手で書き換えたはずの5ファイルが、1つも入っていません。

    作業ディレクトリを見ると、posts(first: 20) のまま戻っていました。書き換えたはずの内容が、どこにも残っていません。

    何が起きていたか

    git reflog に答えがありました。

    git reflog
    d370f37 HEAD@{0}: commit: fix: 記事一覧の上限を100に上げ...
    682faf3 HEAD@{1}: reset: moving to HEAD     ← これ
    682faf3 HEAD@{2}: pull: Fast-forward

    pullの直後、コミットの前に reset が挟まっています。git reset --hard は、コミットしていない変更を問答無用で消します。元に戻す手段はありません。

    つまり、書き換え → リセットで消滅 → その状態でcodegen → コミット、という順番で進んでいました。

    reset は「取り消し」ではなく「破棄」

    直前の操作を取り消したいときに git reset --hard を使いたくなりますが、これはコミット済みの履歴を戻す道具です。まだコミットしていない作業は、履歴に無いので戻せません。作業を残したまま状態を整えたいなら git stash のほうです。

    なぜ「成功」してしまったのか

    ここが今回いちばん考えさせられた点です。

    変更が消えているのに、なぜビルドが通ったのか。答えは、消えた結果として、全部が整合していたからです。

    クエリ側 生成された型 結果
    意図した状態 first: 100 first: 100 通る
    実際の状態 first: 20 first: 20 通る
    もし片方だけ残っていたら first: 100 first: 20 落ちる

    中途半端に消えていれば、ビルドが落ちて気づけました。きれいに全部消えたので、辻褄が合ってしまったわけです。

    ビルドが検証しているのは「矛盾が無いこと」であって、「意図が反映されていること」ではありません。何も変えていないコードは、当然のように矛盾しません。

    緑は「壊れていない」の意味

    CIやビルドの成功は、壊れていないことの証明です。直っていることの証明ではありません。この2つを同じものだと思っていると、今回のように「全部が緑なのに何も進んでいない」状態を見逃します。

    では、生成されたファイルの差分は何だったのか

    もうひとつ不思議だったのが、消えているのに gql.ts だけ差分が出ていたことです。

    調べると、生成されていたのは別のクエリの型でした。sitemap用に書いた、こういう文字列です。

    sitemapPosts.ts
    const POSTS_FOR_SITEMAP = /* GraphQL */ `
      query GetPostsForSitemap {
        posts(first: 100) { ... }
      }
    `;

    このファイルは、型生成に依存したくないからこそ素の文字列で書いていました。ところが先頭に付けた /* GraphQL */ という目印を、codegenが「これはクエリだ」と認識して走査対象に含めていました。

    結果として、型生成を避けるために作ったファイルが、型生成の対象になっていたわけです。意図とは正反対でした。

    失敗のおかげで見つかった

    この矛盾は、単体では表に出ません。使われない型がひっそり生成されるだけで、何も壊れないからです。別の失敗を調べていて、たまたま見つかりました。目印を外して、対象から除きました。

    次から何を見るか

    再発を防ぐために増やした確認は、1つだけです。

    コミットする前に
    git status --short

    並んでいるファイルの数を数えます。5ファイル触ったなら、5ファイル並んでいるはずです。2つしか無ければ、その時点でおかしい。

    やり直したときは、実際にこれで確認しました。7ファイル(手で書いた5つ + 生成された2つ)が並んでいることを見てからコミットしています。

    1回目 2回目
    コミットに入ったファイル 2 7
    ビルド 通った 通った
    本番の日時 変わらず Z が付いた

    1行目だけが、両者を区別できていました。

    今回の学び

    まず、ビルドの成功は「壊れていない」であって「直っている」ではないこと。今回は変更が丸ごと消えたことで整合が取れてしまい、すべての工程が緑になりました。矛盾を検査する仕組みは、何もしていない状態を歓迎します。

    次に、結果ではなく、入力を確認すること。「本番の表示が変わったか」で気づくのは最後です。コミットに何ファイル入ったかを見れば、pushする前に止められました。確認は下流より上流に置くほうが安いです。

    そして、git reset --hard は取り消しではないこと。コミットしていない作業に対しては、単なる破棄です。状態を整えたいだけなら stash を使う、という区別を持っておくと事故が減ります。

    最後に、避けたはずの仕組みに、目印ひとつで巻き込まれること。型生成から独立させるつもりのファイルが、コメント1つで走査対象になっていました。意図は、コードのどこかに具体的な形で表れていないと守られません。

  • 上げる先が、ひとつしかなかった

    上げる先が、ひとつしかなかった

    脆弱性の警告が28件。そのうち23件が next に集約されることまでは分かっていました。あとは上げるだけ——のはずが、まず「どこまで上げるか」で手が止まりました。

    この記事で扱うこと

    更新先のバージョンをどう選んだか、自動化ツールがやってくれたこととやってくれなかったこと、そして「入れる必要のない設定」を消した判断。メジャーバージョンを2つ跨ぐ更新を、確認しながら進めた記録です。

    まず、選択肢を数える

    手元は 14.2.35 でした。自動で提案されていたのは 16.3.1。2世代ぶんの差があります。

    最初に考えたのは「まず14系の最新まで上げて、それで消える分を消そう」でした。メジャーを跨がなければ壊れにくいからです。これは実行しませんでした。調べたら、前提が違っていたからです。

    サポート状況
    Next.js 14  サポート終了(2025-10-26)
                最終パッチ 14.2.35 ← 手元にあるのがこれ
    
    Next.js 15  Maintenance LTS  2026年10月まで
    Next.js 16  Active LTS       2027年10月まで

    14系の最新は、すでに手元にありました。「上げる」という操作に余地が無く、18件の警告は14に留まる限り1件も消えません。

    やっても何も起きないコマンドを、実行しかけた

    調べる前は「14系の最新に上げましょう」と判断していました。実行すれば何かが変わるはず、という思い込みです。手を動かす前にサポート期限を1つ調べただけで、この無駄が消えました。作業の前に前提を確認するのは、遠回りに見えて速いです。

    15を選ばなかった理由

    残る選択肢は15と16です。15の方が変更が小さいので、一見こちらが安全に見えます。

    しかし15のサポートは2か月後に切れます。そこで上げても、すぐ同じ作業をやり直すことになります。「変更が小さい」ことの価値は、その変更が長持ちするかどうかで決まります。2か月で無効になるなら、小さいことに意味がありません。

    実質的に、上げる先は16しかありませんでした。

    何が壊れるかを、先に調べる

    移行ガイドを読んで、このサイトに該当するものだけを抜き出しました。全部を読むのではなく、自分のコードに関係するものを探すという読み方です。

    変更点 このサイトへの影響
    params が非同期になる 該当。記事ページで同期的に使っている
    React 19.2 が必要 該当。18.3.1 から上げる必要がある
    next lint の廃止 該当。package.json に書いてある
    画像設定の既定値変更 明示指定していないので影響なし
    Turbopackが既定に webpack設定を持っていないので影響なし
    middleware の改名 使っていない

    該当したのは3つだけでした。2世代ぶんの変更に見えて、実際に手を入れる場所は限られていると分かった時点で、作業の見通しが立ちます。

    自動化ツールは、半分やってくれる

    Next.jsには移行用のコードモッド(コードを機械的に書き換えるツール)が用意されています。2つ実行しました。

    実行したもの
    # パッケージ更新・設定ファイルの書き換え・lintの移行
    npx @next/codemod@canary upgrade latest
    
    # params / searchParams の非同期化
    npx @next/codemod@canary next-async-request-api .

    変換結果は正確でした。たとえば記事ページはこうなります。

    自動変換された箇所
    - type Props = { params: { slug: string } };
    + type Props = { params: Promise<{ slug: string }> };
    
    - export default async function BlogPostPage({ params }: Props) {
    + export default async function BlogPostPage(props: Props) {
    +   const params = await props.params;

    49ファイルを走査して2ファイルを変更、エラー0。手で直していたら見落としが出る種類の作業です。

    変換されなかった場所も、確認する

    「0 errors」は「全部やった」ではありません。ツールが認識できなかった書き方は、静かに素通りします。変更されたファイル数と、自分が想定していた箇所の数が合っているかは、自分で確かめる必要があります。今回は2ファイルで一致していました。

    入れる必要のない設定を、消した

    ここが今回いちばん判断が要った箇所です。

    コードモッドは、7つのファイルにこれを追加していました。

    追加されていたもの
    // TODO: Cache Components adoption. Refactor this route so this
    // opt-out can be removed.
    export const instant = false;

    調べると、これは16.3の新機能に対するオプトアウトで、別の設定を有効にしているときにだけ意味を持ちます。このサイトはその設定を使っていないので、7ファイルすべてで何もしていません。

    将来のために先回りして入れてくれたものです。しかし残すとどうなるかを考えました。

    残す場合 消す場合
    将来その機能を有効にしたとき、警告が出ない そのとき改めて入れる必要がある
    「あとで対応する」というTODOが7か所に残る コードに無効な設定が残らない

    消しました。誰も対応しないTODOは、そのうち景色になります。読むたびに「これは何だっけ」と確認するコストが7か所ぶん発生し、いずれ誰も読まなくなります。必要になったときに、必要な場所へ入れるほうが健全だと判断しました。

    自動生成されたものを、そのまま受け取らない

    コードモッドが入れたものは正しい提案です。しかし「このプロジェクトにとって必要か」までは判断していません。ツールは一般的に安全な側へ倒します。プロジェクトの事情を知っているのは書き手のほうです。

    本番に入れる前に、本番と同じもので確かめる

    今回はmainに直接入れず、ブランチを切りました。

    進め方
    git checkout -b feat/next-16
    (コードモッド実行・手直し)
    npm run build      ← 手元で通ることを確認
    git push -u origin feat/next-16

    ブランチをpushすると、デプロイ先がプレビュー用のURLを作ってくれます。本番と同じビルド手順で、本番と同じ環境に載った状態を、公開せずに触れます。

    そこで確認したのは6点です。

    確認項目
    1. 記事一覧が20本、サムネイルが出る
    2. 記事ページの本文・目次・読了バー
    3. sitemap.xml が25件(数を数える)
    4. robots.txt が4行
    5. 構造化データの日時に Z が付いている
    6. ビルド時のエラー・警告
    3番は、数えないと分からない

    sitemapは、失敗しても固定ページだけを返して200で応答する作りにしてあります。壊れていても画面は正常に見えます。「表示された」ではなく「25件あった」まで確認しないと、記事がsitemapから消えていても気づけません。

    全部通ったのを見てから、mainへマージしました。プレビューで通ったものが本番でも通る——確認の順番として、これが一番安く済みます。

    結果

    脆弱性の件数
    next     18件 → 0
    postcss   4件 → 0
    nanoid    1件 → 0
    glob      3件 → 0
    lodash    2件 → 2   (開発用のみ)
    
    合計     28件 → 2件

    glob が消えたのは想定外でした。ESLintも一緒に上がり、その依存として新しい glob が入ったためです。本番で動くもののうち、既知の脆弱性は無くなりました。

    残った2件は開発用の依存で、配信される成果物には入りません。

    今回の学び

    まず、「上げる」の前に、上げ先を数えること。14系の最新はすでに手元にあり、実行しても何も起きないコマンドを打ちかけていました。サポート期限を1つ調べるだけで防げます。

    次に、変更の小ささより、その変更が持つ期間で選ぶこと。15は変更が小さいですが2か月で切れます。安全に見える選択肢が、実は先送りなだけということがあります。

    そして、自動化ツールの出力も、読んでから受け取ること。コードモッドは正確でしたが、このプロジェクトに不要なものも入れていました。ツールは一般的に安全な側へ倒し、個別の事情までは知りません。

    最後に、本番と同じもので確かめてから本番に入れること。プレビュー環境を1回挟むだけで、「マージしてから気づく」が「マージ前に気づく」に変わります。確認の順番を変えただけで、リスクの大きさが変わります。

  • 依存の中の依存

    依存の中の依存

    pushしたら、GitHubからこう返ってきました。

    push時のメッセージ
    remote: GitHub found 29 vulnerabilities on kisaku39/kisaku-portfolio's
    remote: default branch (13 high, 14 moderate, 2 low).

    29件。数字だけ見ると、手のつけようがないように感じます。ところが中身を分類すると、実質3つの問題でした。

    この記事で扱うこと

    警告の件数をそのまま受け取らずに読み替える方法、自動で出てくる更新PRの中で「そのまま入れてよいもの」の見分け方、そして「直接の依存を上げたのに警告が消えない」という現象の正体。

    件数は、そのままの意味ではない

    一覧を開いて、パッケージ別に数え直しました。

    パッケージ 件数 用途
    next 19 本番で動く
    postcss 5 ビルド時に動く
    lodash 2 開発用のみ
    glob 1 開発用のみ
    nanoid 1 本番で動く

    29という数は、1つのパッケージに対する報告が積み上がった結果でした。nextの19件は、SSRF、Server ComponentsのDoS、画像最適化APIのDoS、キャッシュ混同——別々の問題ですが、直すのは「nextを上げる」の1手です。

    件数ではなく、手数を数える

    29件を29個の作業だと思うと、着手する気力が湧きません。実際に必要な操作は5つ以下でした。警告システムは問題の個数を報告しますが、こちらが知りたいのは作業の個数です。ここを読み替えないと、量に圧倒されて何もしないことになります。

    開発用かどうかで、優先度が変わる

    一覧には Development という表示が付くものがあります。lodash と glob がそれでした。

    これらはビルドや開発中にしか動かず、配信される成果物には入りません。つまり、サイトを見に来た人がこの脆弱性に触れることはありません。悪用するには、まず開発環境に入り込む必要があります。

    危険がゼロという意味ではありませんが、本番で動くものと同じ緊急度で扱う必要はありません。3件がここに該当したので、後回しにしました。

    自動で出てくるPRは、同じ安全さではない

    Dependabotは修正用のPRを自動で作ってくれます。5本ありました。

    開いていたPR
    #7   next               14.2.35 → 16.3.1    メジャー2つ跨ぎ
    #5   eslint-config-next 14.2.35 → 16.3.1    メジャー2つ跨ぎ
    #6   tailwindcss        3.4.4   → 4.3.3     メジャー跨ぎ
    #8   graphql-request    6.1.0   → 7.4.0     メジャー跨ぎ
    #10  postcss            8.5.23  → 8.5.26    パッチのみ

    ボタンひとつでマージできます。しかし、同じ「更新」でも中身の危険度がまったく違います。

    バージョンの変化 意味 扱い
    8.5.23 → 8.5.26 不具合修正のみ。使い方は変わらない そのまま入れてよい
    6.1.0 → 7.4.0 使い方が変わっている可能性がある 動作確認が要る
    14.2.35 → 16.3.1 2世代ぶんの変更が入る 移行作業として扱う
    GitHub上でマージすると、確認の機会が無い

    手元で更新する場合は npm run build で通ることを確かめてからpushできます。PRをブラウザでマージすると、その工程が丸ごと飛びます。通るかどうかは、本番のデプロイが動き出してから分かります。パッチ更新なら許容できますが、メジャー更新でこれをやるのは賭けです。

    そこで、#10だけをマージしました。チェックも2つとも通っていたので、これは安全な1本です。

    1件しか減らなかった

    マージ後、件数を数え直しました。

    結果
    29件 → 28件
    
    postcss  5件 → 4件

    postcssを上げたのに、postcssの警告が4件残っています。バージョンは 8.5.26 になっているはずです。

    残っている警告を1つ開くと、こう書いてありました。

    アラートの詳細
    影響を受けるバージョン   8.5.11 以下
    修正されたバージョン     8.5.12

    8.5.26 は 8.5.12 より新しいので、条件に当てはまりません。それなのに警告が残っている。

    依存の中の依存

    ロックファイルを直接見て、答えが出ました。

    package-lock.json 内の postcss
    8.5.23 (dev)  node_modules/postcss                ← 8.5.26 に更新された
    8.4.31        node_modules/next/node_modules/postcss ← 手つかず

    postcssが2つ入っていました。

    1つは自分で指定したもの。もう1つは next が自分の内側に抱えているものです。npmは、あるパッケージが要求するバージョンと自分の指定が食い違うとき、そのパッケージの中に別のコピーを置きます。同じ名前のものが、違うバージョンで同居できる仕組みです。

    なぜそういう作りになっているのか

    もし1つしか置けないなら、「AはpostCSS 8.4を要求、BはpostCSS 8.5を要求」という状況で、どちらかが壊れます。入れ子にすることで、互いの要求を同時に満たしています。便利な仕組みですが、その代わり「上げたつもりが上がっていない」が起こります。

    自分で指定した依存は、自分で上げられます。他のパッケージが抱えている依存は、そのパッケージ自身が上げてくれるのを待つしかありません。

    同じことが nanoid にも起きていました。これも next 経由で入っています。

    読み替えた結果

    ここまでを踏まえて、28件を整理し直しました。

    分類 件数 必要な操作
    next 本体 18 next を上げる(1手)
    next が抱える postcss 4
    next が抱える nanoid 1
    開発用(lodash / glob) 5 本番に影響なし。後回し

    28件のうち23件が、1つの操作に集約されました。最初に見た「29件」という数字とは、受ける印象がまったく違います。

    それでも、一気には上げない

    では next を16に上げればいい——とはなりませんでした。

    14から16は、2世代ぶんの変更が入ります。このサイトはApp Routerを使い、ISRで事前生成し、つい先ほどsitemapと構造化データを入れたばかりです。それらが動かなくなる可能性を、確認せずに本番へ出すことはできません。

    段階を分けました。

    今日やること
    npm install next@14 eslint-config-next@14   # 14系の最新まで
    npm run build                               # 通るか確認
    npm run start                               # 本番と同じ条件で表示確認

    まずメジャーを跨がずに取れる分だけ取ります。これで消えない分が、16に上げる判断の材料になります。「全部消したい」から一気に上げると、落ちたときに何が原因か分かりません。

    確認は dev ではなく start で

    開発サーバー(npm run dev)と本番(npm run build → npm run start)では動きが違います。sitemapや構造化データのようにビルド時に生成されるものは、devで見ても本番と同じとは限りません。更新の影響を確かめる目的なら、本番と同じ手順で見る必要があります。

    今回の学び

    まず、警告の件数と、作業の件数は違うこと。29件のうち23件が1つの操作に集約されました。件数のまま受け取ると、量に圧倒されて着手できなくなります。パッケージ別に数え直すだけで、見える景色が変わります。

    次に、「更新」の一言に、危険度の違う操作が混ざっていること。パッチ更新とメジャー更新は、ボタンの見た目が同じでもやっていることが違います。自動で提案されたからといって、同じ扱いをしてよいわけではありません。

    そして、直接の依存を上げても、内側の依存は動かないこと。同じパッケージが違うバージョンで同居できる仕組みがあるため、「上げたのに消えない」が起こります。ロックファイルを見れば10秒で分かる話ですが、知らないと延々と悩みます。

    最後に、本番に届くものと、届かないものを分けること。開発用の依存は、同じ「高」でも読者に届きません。全部を等しく緊急扱いすると、本当に急ぐものに手が回らなくなります。

  • 追従する目次と、画面の取り合い

    追従する目次と、画面の取り合い

    スマホで目次を画面上部に貼り付けたい。

    PCではすでに右側に追従表示していました。同じことをスマホでもやるだけ——のはずが、実際に置いてみると本文を読む場所が足りなくなりました。貼り付くものは、そこにある間ずっと画面を占め続けるからです。

    この記事で扱うこと

    PCとスマホで同じ機能の置き方が変わる理由、貼り付く要素の高さをどこまで削るか、そして「飛んだ先が隠れる」という副作用への対処。狭い画面での面積の取り合いの話です。

    PCとスマホで、余っているものが違う

    PCの画面では、本文の横に十分な余白があります。だから目次は右側に置けます。本文の面積を1ピクセルも削らずに、常に見えている状態を作れます。

    スマホには横の余白がありません。置けるのは本文の上か下だけです。そして上に貼り付ければ、その高さのぶん本文が押し出されます。

    余っているもの 目次の置き場所
    PC 横幅 右側に追従(本文は削られない)
    スマホ なし 上に追従(本文が削られる)

    同じ「追従する目次」でも、払っている代償が違います。PCでの成功をそのまま持ち込むと、スマホでは負担のほうが大きくなる可能性があります。

    最初の実装は、高すぎた

    もともとスマホ版の目次には、進捗バーと「読了まで/あと35%」の表示が縦に積まれ、その下に「目次(8項目)」の開閉ボタンがありました。これをそのまま貼り付けると、こうなります。

    画面上部が占められる高さ
    ヘッダー        57px
    目次バー        85px
    ────────────────────
    合計           142px
    
    iPhone SE の表示領域(667px)に対して 21%

    2割が常時埋まります。しかもスマホでは、画面の下端もブラウザのUIに取られています。実際に本文が見えている範囲は、思っているより狭いのです。

    縦に積むのをやめる

    減らせるものを探しました。表示している情報は3つです。

    要素 役割 削れるか
    「目次」の文字と開閉 触れることを伝える 削れない
    進捗バー どこまで来たかを示す 削れない
    残り%の文字 見通しを与える 削れない

    どれも消せません。消せないなら、並べ方を変えます。縦に積んでいたものを横一列にしました。

    変更後の並び
    [目次 ▸] ──────────── あと 35%
               (進捗バー)

    これで85pxが約40pxになりました。ヘッダーと合わせて約97px、本文が見える範囲は85%まで戻ります。情報は1つも減らしていません。

    削る前に、並べ替えを試す

    置く場所が足りないとき、最初に浮かぶのは「何を消すか」です。しかし消すと機能が減ります。同じ情報を、置ける形に合わせて並べ替えられないかを先に考えると、失わずに済むことがあります。

    実装:同じ部品を2通りに並べる

    進捗バーの部品には、並べ方を選べるようにしました。

    ReadingProgress.tsx(要点)
    layout?: "stacked" | "row";
    
    // 表示する部品は3つだけ。並べ方が2通りあるので、
    // 部品を変数に切り出してから並べ替える。
    const finishedLabel  = ( ... );
    const remainingLabel = ( ... );
    const bar            = ( ... );
    
    if (layout === "row") {
      return (
        <div className="flex items-center gap-2.5">
          <div className="min-w-0 flex-1">{bar}</div>
          {finished ? finishedLabel : remainingLabel}
        </div>
      );
    }

    ここで避けたかったのは、レイアウトごとに同じJSXを書き写すことです。書き写すと、表示内容を変えたときに片方だけ直し忘れます。部品を変数にしておけば、並べ替えても中身は1つのままです。

    飛んだ先が、バーの裏に隠れる

    実装して気づいた副作用です。

    目次のリンクを押すと、その見出しまでスクロールします。ところが貼り付いたバーの裏に見出しが潜り込んで見えなくなりました。ブラウザは「見出しを画面の一番上に持ってくる」ことしかしないので、上に何かが被さっていることを知りません。

    対処
    @media (max-width: 1023px) {
      .wp-content h2,
      .wp-content h3 {
        scroll-margin-top: 8.5rem;   /* ヘッダー + 目次バー + 余白 */
      }
    }

    scroll-margin-top は「スクロールで停止する位置を、この分だけ手前にずらす」指定です。PCには目次バーが無いので、幅の狭い画面だけに適用しています。1024pxという境目は、目次がサイドバーからバーに切り替わる位置に合わせました。

    押しても何も起きていないように見える

    この不具合が厄介なのは、スクロール自体は正しく動いていることです。見出しは画面外ではなく、バーの1段下に隠れているだけ。しかし使う側からは「押したのに反応しない」としか見えません。動作の正しさと、体験の正しさは別だという例です。

    開いたときの高さも止める

    もうひとつ。目次を開いたときの高さにも上限が要ります。

    見出しが8個ある記事だと、開いた目次は約340pxありました。ヘッダーと合わせて約400px。667pxの画面では、飛び先を選んでいる間、本文がほとんど見えません。

    上限を付ける
    max-h-[50vh] overflow-y-auto

    画面の半分までに制限し、それを超える場合は目次の中だけをスクロールさせます。これで、どんなに見出しが多い記事でも本文が半分は見えたままになります。

    記事が終われば、消える

    最後にひとつ、意図してそうしている挙動があります。

    この目次バーは記事本文のカードの中に置いてあります。position: sticky は親要素の範囲でしか効きません。だから記事本文が終わると、バーも一緒に流れて消えます。

    画面の最上端に固定する方法もありましたが、そうすると読み終えた後の「前後の記事」や「一覧へ戻る」を見ているときにも居座り続けます。もう使わないものが場所を取り続けるのは、狭い画面では特に邪魔です。

    今回の学び

    まず、PCでうまくいった置き方が、スマホでも良いとは限らないこと。PCの追従目次は本文の面積を削りませんが、スマホでは削ります。同じ機能でも、払う代償が違います。

    次に、消す前に並べ替えを試すこと。場所が足りないときに情報を削ると、機能が減ります。縦を横にするだけで、失わずに半分の高さに収まりました。

    そして、動作の正しさと体験の正しさは別だということ。目次リンクのスクロールは仕様どおり動いていましたが、使う側には「反応しない」と見えていました。正しく動いているかだけを確認していると、この種の問題は見つかりません。

    最後に、貼り付くものには終わりを決めておくこと。ずっと画面にあるものは、必要な間だけあればいい。記事の中でだけ生きて、読み終えたら消える——親要素の範囲で自然にそうなる形を選びました。

  • 推測させるのをやめる

    推測させるのをやめる

    検索エンジンに、推測させるのをやめる。

    今回入れたのは3つです。sitemap.xml、robots.txt、そして構造化データ。バラバラの施策に見えますが、やっていることは同じです。これまで相手に推測させていたことを、こちらから明示的に伝える。それだけです。

    この記事で扱うこと

    3つがそれぞれ何を伝えているか、順位と発見速度は別物だという話、そして実装時に踏んだ「一覧用のクエリを流用してはいけなかった」という落とし穴。SEOの小手先ではなく、仕組みとして何が起きているかの話です。

    3つとも「伝える」ための仕組み

    先に全体像を置きます。

    伝えていること 無いとどうなるか
    sitemap.xml どんなページが存在するか リンクをたどって発見されるまで待つ
    robots.txt どこを見てよいか / 地図の場所 全部を手探りで巡回される
    構造化データ そのページが何なのか HTMLの見た目から推測される

    右の列に共通しているのは、「無くても動くが、相手の推測に任せることになる」という点です。推測は当たることもあれば外れることもあり、こちらからは制御できません。

    sitemap.xml — 順位ではなく、見つかるまでの時間

    まず誤解を解いておきます。sitemapを置いても、検索順位は上がりません。

    これは「見つけてもらうまでの時間」を短くするものです。検索エンジンは通常、リンクをたどってページを見つけます。外部からのリンクがまだ少ない新しいサイトでは、存在しているのに気づかれない期間が生まれます。sitemapは、そのたどる作業を省いて一覧を直接渡す仕組みです。

    Next.jsのApp Routerでは、app/sitemap.ts に既定の関数を書くだけで /sitemap.xml が生成されます。XMLを自分で組み立てる必要はありません。

    app/sitemap.ts(要点)
    export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
      const staticEntries = [
        { url: `${SITE_URL}/`,     changeFrequency: "weekly",  priority: 1   },
        { url: `${SITE_URL}/blog`, changeFrequency: "weekly",  priority: 0.8 },
        // ...
      ];
    
      const posts = await getPostsForSitemap();
      // ...
    }
    priority は「相対的な」重要度

    0から1の値ですが、これは検索エンジンに対する評価の申告ではありません。サイト内での相対的な優先度です。全部を1.0にすると差が無くなり、指定していないのと同じになります。

    20本の壁

    実装中に気づいた問題です。ここが今回いちばんの学びでした。

    記事一覧を取るための関数は、すでに用意してありました。getPosts() です。sitemapもこれを使えば済む——と思ったのですが、中身を見て手が止まりました。

    一覧用のクエリ
    query GetPosts {
      posts(first: 20) {   ← 20本で打ち切っている
        ...
      }
    }

    一覧ページに20本並べる、という表示上の都合でこうなっていました。これをsitemapに流用すると、21本目以降は「存在するのに検索エンジンへ知らせない記事」になります。

    気づかないまま進む種類の不具合

    この漏れは、エラーになりません。sitemapは正常に生成され、中身も正しく見えます。足りないことに気づく手がかりが、どこにもありません。公開記事がちょうど20本だったので、次の1本を公開した瞬間から静かに始まるところでした。

    教訓は単純です。「一覧に何本並べるか」と「検索エンジンに何本知らせるか」は別の話でした。同じ「記事一覧」という名前がついているせいで、同じものに見えていただけです。

    sitemap専用に、全件を取る関数を分けました。

    型生成への依存を、持ち込まない

    ここでもうひとつ判断が要りました。

    このプロジェクトのGraphQLクエリは、graphql-codegen が生成した型付きのドキュメントを経由しています。型が付くのは大きな利点ですが、制約がついてきます。クエリを1文字でも変えたら、型を作り直さなければなりません。そして作り直しにはCMSへ接続してスキーマを読む必要があります。

    得られるもの 引き換えに背負うもの
    型付きクエリ 取り違えを防げる CMSに繋がる環境でしか変更できない
    素の文字列 どこでも書ける 型は自分で書く

    sitemapが必要としているのは slug と modified の2つだけです。2つしか無いものを、取り違えようがありません。ここだけ素の文字列で問い合わせ、型は手で書くことにしました。

    仕組みの利点は、全部に適用しなくてよい

    型生成は良い仕組みですが、それに乗ることで「CMSに繋がらないと作業できない」という性質も一緒に受け取ります。その性質が邪魔になる場所だけ、外れてよいと判断しました。一貫性のために全部を揃えると、揃えた不便さも全部に広がります。

    robots.txt — 守っていないもの

    app/robots.ts を置くと /robots.txt が生成されます。中身はこれだけです。

    生成された robots.txt
    User-Agent: *
    Allow: /
    
    Host: https://www.kisaku.site
    Sitemap: https://www.kisaku.site/sitemap.xml

    ここで大事なのは、robots.txt はアクセス制限ではないことです。「クロールしてよい場所」を伝える取り決めであって、強制力はありません。守るかどうかは相手次第です。

    隠したいものを書いてはいけない

    「見られたくないから Disallow に書く」という使い方は逆効果です。robots.txt は誰でも読めるので、「ここに何かある」と書き残すことになります。本当に見せたくないものは、認証をかけるか、そもそも公開領域に置かないかのどちらかです。

    今回の実質的な役割は最後の1行、sitemapの場所を知らせることです。これがあると、Search Consoleに登録していない検索エンジンもsitemapを見つけられます。

    Host を書いているのは、このサイトが kisaku.site を www 側へ寄せているためです。どちらが正なのかを明示しています。

    構造化データ — 見た目から意味を切り離す

    3つめです。記事ページに、こういうものを埋め込みました。

    出力されるJSON-LD(抜粋)
    {
      "@type": "BlogPosting",
      "headline": "wwwのあるなしで、別のサイトになる",
      "datePublished": "2026-08-22T13:20:00",
      "dateModified":  "2026-08-22T14:21:46",
      "author":    { "@type": "Person", "name": "KiSAKU" },
      "publisher": { "@type": "Person", "name": "KiSAKU" }
    }

    画面には何も出ません。「これは記事で、公開日はこれで、書いたのはこの人」と直接伝えているだけです。

    これが無い場合、検索エンジンはHTMLの構造から推測します。「大きな文字だから見出しだろう」「日付っぽい文字列があるから公開日だろう」と。たいてい当たりますが、デザインを変えると解釈が変わることがあります。

    見た目を変えても、意味は動かない

    明示しておけば、日付の表示位置を変えても、見出しのサイズを変えても、伝わる内容は同じままです。装飾の変更が、意図せず情報を壊さない状態になります。

    パンくずも一緒に出す

    BreadcrumbList も併記しています。検索結果に出るパンくず表示のためです。

    階層
    ホーム › ブログ › (記事タイトル)

    ここで書く階層は、画面上の構造と一致させる必要があります。実際には無い階層を書くと、検索結果と実際のサイトが食い違い、逆効果になります。

    タグが途中で閉じないようにする

    細かいですが、実装上の注意点です。JSON-LDは <script> タグの中に書きます。もし記事タイトルに </script> という文字列が入っていたら、そこでタグが閉じてしまいます。

    対処
    JSON.stringify(jsonLd).replace(/</g, "\\u003c")

    < をユニコードのエスケープ表記に置き換えています。JSONとしての意味は変わらず、HTMLとしては無害になります。自分で書いた記事しか入らない場所でも、こういう箇所は塞いでおくほうが安いです。

    入れたら、実物で確かめる

    3つとも、公開後に実際のURLを叩いて確認しました。

    確認
    https://www.kisaku.site/robots.txt   → 4行が返る
    https://www.kisaku.site/sitemap.xml  → 固定ページ5 + 記事20 = 25件
    記事ページ → script[type="application/ld+json"] を読んで中身を検証

    特にsitemapは、件数を数えるまで安心できません。今回の20本の壁のように、正しく生成されているのに中身が足りない、という失敗があり得るからです。「エラーが出ていない」は「正しい」ではありません。

    今回の学び

    まず、3つとも「順位を上げる」ものではないこと。sitemapは発見までの時間を短くし、robots.txtは地図の場所を教え、構造化データは意味の推測を減らします。効果を正しく理解していないと、成果が出ないときに何を疑えばいいか分からなくなります。

    次に、似た名前のものを、同じものだと思わないこと。「記事一覧」という同じ名前でも、表示のための一覧と、検索エンジンへ渡す一覧は要件が違いました。流用は速いですが、要件が違うものを流用すると、後から静かに壊れます。

    そして、仕組みの利点は、全部に適用しなくてよいこと。型生成は良い仕組みですが、乗ると「CMSに繋がらないと変更できない」という性質も付いてきます。その性質が邪魔になる1か所だけ、外れる判断をしました。

    最後に、エラーが出ないことは、正しいことではないこと。sitemapの20本上限は、何のエラーも出さずに記事を落とし続けるところでした。出力されたものを数える、という確認をしなければ気づけませんでした。

  • wwwのあるなしで、別のサイトになる

    wwwのあるなしで、別のサイトになる

    kisaku.site と www.kisaku.site。どちらを開いても同じサイトが出ます。

    しかし機械から見ると、この2つは別のサイトです。今回、計測まわりの設定を3か所やって、そのすべてで「どちらを書くか」を選ばされました。選び方を間違えると、設定したのに数字がいつまでも0のままになります。

    この記事で扱うこと

    wwwありとwwwなしが別物として扱われる理由、apexドメインにCNAMEを置けないというDNSの制約、そして計測系の3つ(Search Console・GA4・OGP)で同じホスト名に揃える必要があること。

    まず用語を整理する

    呼び方 例 意味
    apexドメイン
    (ゾーンapex、ネイキッドドメイン)
    kisaku.site いちばん短い形。前に何も付かない
    サブドメイン www.kisaku.site
    cms.kisaku.site/wp/
    apexの前に名前を足したもの

    www は特別なものではありません。cms や blog と同じ、ただのサブドメイン名です。慣習として広まっているだけで、技術的な地位は他と変わりません。

    ここが出発点です。www が特別だと思っていると、なぜ別扱いされるのかが理解できません。

    なぜ「どちらか」に決める必要があるのか

    両方で同じ内容が見られる状態は、放っておくと3つの問題を生みます。

    困ること 何が起きるか
    検索エンジンから見て重複 同じ内容が2つのURLに存在する扱いになり、評価が分散する
    計測が2つに割れる 同じ人が両方を訪れると別の訪問者として数えられる
    Cookieが共有されない 片方でログインしても、もう片方では未ログイン扱いになりうる

    だから正規のアドレスを1つ決めて、もう片方はそこへ転送する。これが標準的なやり方です。このサイトは www.kisaku.site を正規にして、kisaku.site から転送しています。

    なぜ www 側を正規にしたのか

    好みではなく、DNSの制約が理由です。

    ドメインとサーバーを結びつけるとき、レコードの種類を選びます。

    種類 指す先 apexに置けるか
    A / AAAA IPアドレス 置ける
    CNAME 別のドメイン名 置けない

    apexドメインにはCNAMEを置けません。DNSの仕様上、CNAMEは「この名前は別名です」という宣言で、同じ名前に他のレコードを共存させられないためです。apexには NS や MX といったレコードが必ず存在するので、CNAMEと同居できません。

    これが効いてくるのは、VercelやNetlifyのようなサービスを使うときです。こうしたサービスは配信先のIPアドレスを固定していません。負荷や障害に応じて変わります。だからIPではなく名前で指してほしい——つまりCNAMEを求めます。

    ALIAS / ANAME という回避策

    「apexでもCNAMEのように振る舞う」独自レコードを用意しているDNSサービスもあります(ALIAS、ANAME、CNAMEフラットニングなど呼び方はさまざま)。ただしこれはDNSの標準ではなく、各社の独自拡張です。使えるかどうかは契約しているDNSサービス次第で、乗り換えの自由が下がります。

    まとめると、www 側はCNAMEを素直に置けるのに対し、apex側は置けません。制約の少ないほうを正規にして、制約のあるほうを転送元にする。これが www を正規にした理由です。

    逆に、自分でサーバーを持っていてIPアドレスが固定なら、apexを正規にする選択も普通にあります。どちらが優れているという話ではなく、構成によって答えが変わります。

    転送しても、統合はされない

    ここからが今回の本題です。

    転送を設定すると、人が kisaku.site を開いても www.kisaku.site に飛びます。見た目には1つのサイトです。

    しかし計測やSEOのツールは、転送を設定したからといって自動で統合してくれません。登録するときにどちらを書いたかで、拾えるものが変わります。

    設定した場所 登録した値 間違えるとどうなるか
    Search Console
    (URLプレフィックス)
    https://www.kisaku.site/ データが増えないまま。不具合との区別がつかない
    GA4 データストリーム https://www.kisaku.site 設定上の表示がずれる
    Next.js の metadataBase https://www.kisaku.site SNS共有時にOGP画像が出ない

    いちばん厄介なのは1行目です。間違っていても、エラーは出ません。ただ数字が0のままになります。「まだ検索に載っていないのだろう」と思って何週間も待ってしまう、という失敗が起こりえます。

    登録するのは「転送先」

    迷ったら、ブラウザのアドレスバーに最終的に表示されているほうを登録します。転送元ではありません。このサイトなら www 付きです。

    ドメインプロパティなら分かれない

    Search Consoleには、URLプレフィックスとは別にドメインプロパティという種類があります。こちらは www の有無も http / https の違いも区別せず、すべてまとめて扱います。

    URL プレフィックス ドメイン
    対象 入力したURLの配下だけ すべてのサブドメイン・両プロトコル
    確認方法 5種類から選べる DNSのTXTレコードのみ
    www の有無 別プロパティになる 区別しない

    「それなら最初からドメインで登録すればいい」と思えますが、そう単純でもありません。

    ドメインプロパティはすべてのサブドメインを含みます。このサイトには cms.kisaku.site/wp/ というWordPress用のサブドメインがあり、そちらは検索結果に出したくありません。ドメインプロパティにすると、見せたくない側の数字も一緒に入ってきます。

    また、確認方法がDNSのTXTレコード追加に限られます。DNSは設定を間違えるとサイト全体が見えなくなる場所です。手軽さでいえばHTMLタグを1行足すほうが安全です。

    両方持つという選択

    実際には、両方登録して並行して見ることもできます。URLプレフィックスで www 側を細かく見つつ、ドメインプロパティで全体を把握する。どちらか一方を選ばなければならない、という制約はありません。

    フロント側での指定

    Next.jsでは metadataBase に正規のURLを1か所書きます。

    layout.tsx(要点)
    const SITE_URL = "https://www.kisaku.site";
    
    export const metadata: Metadata = {
      metadataBase: new URL(SITE_URL),
      openGraph: {
        type: "website",
        siteName: "KiSAKU Portfolio",
        locale: "ja_JP",
        url: SITE_URL,
      },
    };

    これが必要なのは、SNSは相対パスの画像を読めないからです。OGP画像を /ogp.png のように書いても、共有先のサービスはそれをどのドメインの話か判断できません。metadataBase があると、Next.jsが絶対URLに変換して出力してくれます。

    ここで www なしを書くと、共有時の画像URLが転送を挟む形になります。転送に対応していないサービスでは画像が表示されません。

    確認の仕方

    設定が効いているかは、実際にアクセスして確かめます。

    転送されているかを見る
    curl -sI https://kisaku.site/ | head -5
    
    HTTP/2 308
    location: https://www.kisaku.site/

    308 は「恒久的な転送」です。検索エンジンに対して「こちらが正規です」と伝える意味を持ちます。302(一時的)だと、その意味が伝わりません。

    ブラウザで確認するときの落とし穴

    恒久的な転送はブラウザに強くキャッシュされます。設定を直したのに古い転送先へ飛び続ける、ということが起きます。以前、この勘違いで「本番が更新されていない」と思い込んだことがありました。実際にはURLの末尾に適当なクエリを付けるだけで、正しく転送されていることが確認できました。

    今回の学び

    まず、www は特別な存在ではないということ。cms と同じただのサブドメインです。ここを理解していないと、なぜ別サイト扱いされるのかが腑に落ちません。

    次に、どちらを正規にするかは好みではなく制約で決まること。apexにCNAMEを置けないというDNSの仕様が、そのまま結論を導きました。構成が変われば答えも変わります。

    そして、転送は人には効くが、ツールには効かないこと。転送を設定しても、Search ConsoleもGA4もOGPも、登録した値のとおりに動きます。3か所すべてで同じホスト名に揃える必要がありました。

    最後に、エラーが出ない間違いがいちばん高くつくこと。ホスト名を間違えても、どこにも警告は出ません。ただ数字が増えないだけです。壊れているのか、まだ何も起きていないのか、区別がつきません。沈黙する設定ほど、入れた直後に確かめる価値があります。

  • 「読み終えた」をどう数えるか

    「読み終えた」をどう数えるか

    「この記事は最後まで読まれたのか」を数えたい。

    GA4には最初からスクロール距離の計測がありますが、今回はそれを使いませんでした。使わなかった理由と、代わりに何を測ることにしたか。そして確認の途中で、自分の測り方のほうが間違っていたという話です。

    この記事で扱うこと

    スクロール率で読了を測ることの問題、サイト側にすでにある読了状態を使うという判断、「未読から読了に変わった瞬間」だけを送る実装。最後に、計測は必ず一部を取りこぼすという前提について。

    スクロール率では「読んだ」と言えない

    GA4の拡張計測には scroll というイベントがあり、1回の表示の中で画面が90%まで下がると発火します。設定を1つ入れるだけで使えます。

    それでも使わなかったのは、測っているものが違うからです。

    場面 scrollは発火するか 実際に読んだか
    最後まで読み進めた する 読んだ
    目次から最後の見出しへ飛んだ する 読んでいない
    末尾までスクロールだけした する 読んでいない
    同じ記事を3回開いた 3回する 読了は1回

    下3行が問題です。スクロール率が測っているのは「画面が下まで移動したか」であって、「その人が読み終えたか」ではありません。近い場面が多いので代用できているように見えますが、別のものです。

    特に最後の行は効きます。気に入った記事ほど開き直されるので、読まれている記事ほど水増しされるという、いちばん困る方向に歪みます。

    すでに答えを持っていた

    このサイトには、以前から読了状態を保存する仕組みがあります。記事を最後まで読むと、そのことをブラウザ(localStorage)に記録し、一覧に「読了済み」のバッジを出す機能です。

    つまり「この人がこの記事を読み終えたか」は、すでにサイトが知っていました。計測のために新しく測り直す必要はなく、その状態が変わった瞬間を送ればいい。

    測り直さない

    同じことを2か所で測ると、必ずどこかでずれます。ずれたとき、どちらが正しいかを毎回考えることになります。すでに持っている情報を使うほうが、増やす手間も、あとで悩む回数も少なくて済みます。

    「変わった瞬間」を返させる

    読了を記録する関数は、もともと何も返していませんでした。

    変更前
    export function markAsRead(slug: string): void {
      const current = getReadSlugs();
      if (current.includes(slug)) return;   // すでに読了なら何もしない
      // 保存する
    }

    ここで注目したいのは、この関数はすでに「読了済みかどうか」を知っているということです。3行目で判定しています。知っているのに、その情報を捨てていました。

    そこで、戻り値を持たせました。

    変更後
    export function markAsRead(slug: string): boolean {
      try {
        const current = getReadSlugs();
        if (current.includes(slug)) return false;   // 再訪
    
        window.localStorage.setItem(KEY, JSON.stringify([...current, slug]));
        return true;                                // 新しく読了になった
      } catch {
        // 保存できない環境(プライベートモード等)では判定材料が無い。
        // 呼び出し側がページ内で1回に制限しているので、そこで歯止めがかかる。
        return true;
      }
    }

    呼び出し側は、これが true のときだけ計測へ送ります。

    MarkAsRead.tsx(要点)
    const isNewlyRead = markAsRead(slug);
    if (!isNewlyRead) return;
    
    window.dataLayer.push({
      event: "article_read",
      article_slug: slug,
      article_title: title,
    });

    結果として、1つの記事から送られる読了は、そのブラウザにつき1回だけになります。再訪しても増えません。読了数が再訪回数に化けることがなくなりました。

    呼び出し側で判定しなかった理由

    「読了済みかどうか」を呼び出し側でもう一度調べることもできました。しかしそうすると、同じ判定が2か所に存在します。片方の条件を変えたときに、もう片方を直し忘れます。すでに判定している場所に、判定結果を返させるほうが素直でした。

    タグマネージャー側の設定

    サイトから送られた article_read を、GTMで受け取ってGA4へ流します。作ったのは変数2つ、トリガー1つ、タグ1つです。

    GTMの構成
    変数     DLV - article_slug     データレイヤー変数 article_slug
    変数     DLV - article_title    データレイヤー変数 article_title
    トリガー CE - article_read      カスタムイベント article_read
    タグ     GA4 - article_read     GA4イベント / イベント名 article_read
                                    パラメータに上の2変数を付与

    スラッグとタイトルを両方送っているのは、レポートでどの記事が読み切られたかを記事単位で見るためです。スラッグだけだと一覧が英数字の羅列になり、読めません。

    GTMでバージョンを公開した画面
    公開時には必ず説明を書く。半年後の自分が、どのバージョンに戻せばいいか判断できなくなるため。

    動かないときは、測り方を疑う

    実装が終わり、本番で確認しました。記事を最後までスクロールさせても、読了に切り替わりません。進捗表示は「あと100%」のままでした。

    コードを疑って何度か読み返しましたが、間違いは見つかりませんでした。そこで、確認に使っていたスクリプトのほうを調べました。

    やっていたこと
    document.documentElement.scrollTop = 一番下;

    スクロール位置は確かに変わっていました。しかし進捗を計算している処理は、スクロールイベントを受け取って動きます。この方法では、そのイベントが期待どおりに発生していませんでした。

    イベントを明示的に発生させたところ、即座に読了になり、計測も送られました。

    対象より先に、測り方を疑う

    「動かない」と思ったとき、疑う先はまず対象のコードになります。しかし今回、間違っていたのは確認する側でした。手で操作していれば一度で分かったはずのことに、時間を使いました。以前にも正規表現の数え方を間違えて、コードのせいだと思い込んだことがあります。同じ形の失敗です。

    数字が1件も届かない

    読了イベントがブラウザ内で発火することは確認できました。ところが、GA4の管理画面には「受信したデータはまだありません」と出たままでした。

    サイトからGTMへの受け渡しは、この目で確認しています。止まっているとすれば、その先です。ネットワークの記録を見ました。

    読み込まれていたもの
    chrome-extension://(拡張機能のID)/
        rule_resources/redirects/google-analytics_analytics.js

    広告ブロッカーが、Googleの計測スクリプトを無害化した別のファイルに差し替えていました。gtm.js の取得自体は成功(200)しているのに、コンテナは動いていない。読み込まれてはいるが、実行されていない状態です。

    別の端末で同じ記事を読んだところ、そちらは正しく届きました。

    GA4のリアルタイムでarticle_readが1件記録されている画面
    ページビューに混ざって article_read が1件。読了が数えられている。

    計測は必ず一部を取りこぼす

    この経験には、設定の話より大きな中身がありました。

    閲覧者の一定割合は広告ブロッカーを使っています。つまりアクセス解析の数字は、はじめから実数より少なく出ます。これは不具合ではなく、仕様として受け入れるしかない前提です。

    知らないと、2つの困ったことが起きます。ひとつは、正常なのに「壊れている」と考えて原因を探し続けること。実際、私はしばらくそう考えていました。もうひとつは、数字を実数だと思って判断してしまうことです。

    絶対値ではなく、傾向として読む

    取りこぼしがあっても、記事どうしの比較は成立します。どの記事がよく読まれたか、読了率が高いのはどれか。取りこぼす割合はどの記事でも似た程度なので、比べる分には使えます。「何人が読んだか」ではなく「どちらがよく読まれたか」を見る道具だと考えるのが実態に合っています。

    今回の学び

    まず、近い場面で発火するものは、同じものではないということ。スクロール率は読了と多くの場面で一致しますが、目次から飛んだときも、読まずに流したときも発火します。名前が似ているだけで代用すると、あとで数字の意味を説明できなくなります。

    次に、すでに知っている情報を捨てないこと。読了を記録する関数は、すでに「読了済みかどうか」を判定していました。その結果を返させるだけで、呼び出し側の二重判定がなくなりました。新しく調べるより、判定している場所に返させるほうが素直です。

    そして、動かないときは対象より先に測り方を疑うこと。今回いちばん時間を無駄にしたのがここでした。数が合わないときは、だいたい数え方のほうが間違っています。

    最後に、計測は完全ではないと知ったうえで使うこと。0件を見て慌てるか、「そういうものだ」と分かって傾向を読むか。前提を持っているかどうかで、同じ数字から引き出せるものが変わります。

  • 「来なかった人」を見るために

    「来なかった人」を見るために

    アクセス解析は「来た人」しか教えてくれません。

    検索結果に出たのに押されなかった、そもそも検索結果に出ていない——こうした「来なかった人」の情報は、GA4には一切残りません。それを見るための道具がGoogle Search Consoleです。今回はその導入と、所有権の確認方法を選ぶまでの判断を記録します。

    この記事で扱うこと

    Search ConsoleとGA4の役割の違い、ドメインプロパティとURLプレフィックスの選び分け、5つある所有権確認方法のうちどれを選んだか。そして、確認コードをコードに直接書かなかった理由。

    GA4では見えないもの

    まず、2つの道具が何を見ているのかを整理します。境界を勘違いすると、片方に無いものを延々と探すことになります。

    Googleアナリティクス Search Console
    見る対象 サイトに来た後の行動 サイトに来る前の状況
    データの出どころ サイトに埋めたタグ Googleの検索システム
    分かること 閲覧ページ、滞在、遷移、流入元 検索語、表示回数、順位、クリック率
    分からないこと どんな言葉で探されたか サイト内で何をしたか

    この違いがはっきり出るのが検索語です。GA4は「Google検索から来た」ことは分かりますが、何と入力して来たのかは教えてくれません。プライバシー保護のため、検索エンジン側が渡していないからです。

    Search Consoleは検索エンジン側の記録なので、そこが見えます。さらにクリックされなかった表示も数えられています。これがGA4には原理的に存在しない情報です。

    表示されているのに押されていない、が分かる

    ある記事が検索結果に100回出て、クリックが2回だとします。GA4に残るのは「2」だけです。Search Consoleを見ると「100回出ている」と分かるので、内容ではなくタイトルの問題だと切り分けられます。逆に表示が0なら、書き方以前に見つけられていません。直す場所がまったく変わります。

    プロパティの種類を選ぶ

    登録の最初に、2種類のどちらかを選ばされます。

    ドメイン URL プレフィックス
    対象 すべてのサブドメイン、http/https両方 入力したアドレスの配下だけ
    確認方法 DNSの設定のみ 5種類から選べる
    向いている場面 サブドメインをまとめて見たい 特定の1サイトだけ見たい

    今回はURLプレフィックスを選び、https://www.kisaku.site/ を登録しました。

    理由は2つあります。ひとつは、このサイトには cms.kisaku.site/wp/ というWordPress用のサブドメインがあり、そこは検索結果に出したくないからです。ドメインプロパティにすると、見せたくない側の数字まで一緒に入ってきます。

    もうひとつは、確認方法の選択肢です。ドメインプロパティはDNSレコードの追加が必須で、ドメイン管理会社の画面での作業になります。設定を間違えるとサイト全体が見えなくなる場所なので、必要のない作業は入れたくありませんでした。

    wwwの有無は別物として扱われる

    URLプレフィックスでは、https://kisaku.site/ と https://www.kisaku.site/ は別のプロパティです。転送を設定していても自動では統合されません。登録するのは転送先——つまり実際に表示されるほうです。ここを間違えると、いつまでもデータが0のままになります。

    所有権の確認方法

    URLを入力すると、そのサイトが自分のものだと証明するよう求められます。方法は5つ提示されました。

    方法 やること 今回の判断
    HTMLファイル 指定のファイルをサイト直下に置く ×
    HTMLタグ metaタグを head に入れる 採用
    Googleアナリティクス GA4のタグが入っていれば通る ×
    Googleタグマネージャー GTMのタグが入っていれば通る ×
    DNSレコード ドメイン設定にTXTを追加 ×

    HTMLファイル方式を外したのは、Next.jsで public に置くこと自体はできるものの、この1ファイルが何のためにあるのか、後から見て分からなくなるからです。ファイル名は意味を持たない英数字の羅列で、消しても当面は気づきません。そして消すと所有権が外れます。

    GA4やGTMを使う方式も外しました。一見いちばん楽ですが、計測を止めた瞬間に所有権の確認も外れます。将来「GTMをやめて直接タグを貼ろう」と判断したとき、Search Consoleまで巻き込まれます。関係のないもの同士を依存させない、という判断です。

    DNSは前述のとおり、影響範囲が大きすぎるので今回は使いませんでした。

    残ったHTMLタグ方式を選んでいます。head に1行入るだけで、これはNext.jsが正式に対応している範囲です。

    確認コードを直接書かなかった理由

    提示されるのはこういう1行です。

    Search Consoleが提示するタグ
    <meta name="google-site-verification" content="(英数字の文字列)" />

    これを layout.tsx にそのまま書いても動きます。しかし、環境変数から読む形にしました。

    layout.tsx(要点)
    export const metadata: Metadata = {
      metadataBase: new URL("https://www.kisaku.site"),
      // ...
      verification: {
        google: process.env.NEXT_PUBLIC_GOOGLE_SITE_VERIFICATION,
      },
    };

    Next.jsには verification という専用の項目が用意されていて、値を渡すと head にmetaタグを出力してくれます。手書きでタグを差し込む必要はありません。

    環境変数にした理由は、値が未設定なら何も出力されないからです。コードを先にデプロイしておいて、確認コードを取得してから設定を足せます。実際、今回はコードのほうが先にできていました。

    もうひとつ、この値がサイトの機能に関係ないものだという点も理由です。動作には一切影響しないので、コードの中に混ざっていると「何のための文字列か」を毎回思い出すことになります。設定として外に置けば、設定であることが見た目で分かります。

    この値は秘密ではない

    NEXT_PUBLIC_ が付いた環境変数は、ビルド時にコードへ埋め込まれ、誰でも見られる状態になります。この確認コードはもともとHTMLに出力するためのものなので、それで問題ありません。逆に言えば、秘密にすべき値をこの接頭辞で扱ってはいけません。接頭辞が「公開してよい」という宣言になっています。

    登録しただけでは確認されない

    metaタグがまだ本番に出ていないので、この時点でのプロパティは未確認の状態です。

    手順としてはこうなります。

    確認が通るまでの順番
    1. Search Consoleでプロパティを追加し、確認コードを控える
    2. Vercelの環境変数に確認コードを登録する
    3. 再デプロイする(環境変数の追加だけでは反映されない)
    4. Search Consoleに戻って「確認」を押す
    環境変数は追加しただけでは効かない

    NEXT_PUBLIC_ の値はビルド時にコードへ埋め込まれます。管理画面で追加しても、その後にビルドし直さなければ、出力されるHTMLは前のままです。「設定したのに反映されない」の大半はこれです。

    確認が通ったあとは、サイトマップの送信が次の作業になります。sitemap.xml をまだ用意していないので、これは別のタスクとして残しました。

    今回の学び

    まず、2つの道具は「来た後」と「来る前」で分かれているということ。GA4だけを見ていると、記事が読まれない理由を内容の問題だと考えがちです。実際には検索結果に出ていないだけかもしれません。直す場所を間違えないために、両方が要ります。

    次に、楽な確認方法ほど、他のものに依存していること。GA4やGTMを使う方式は一瞬で終わりますが、計測をやめた瞬間に所有権も外れます。関係のないもの同士を結びつけない——遠回りに見えても、独立させておくほうが後で自由が利きます。

    そして、設定はコードの外に置くと、設定に見えること。確認コードを直接書いても動きますが、コードの中に混ざると意味を思い出す手間が毎回かかります。環境変数にしておけば、それが「あとで変わりうる値」だと見た目で分かります。

  • ページを10枚見ても「1」と数えられる問題

    ページを10枚見ても「1」と数えられる問題

    ページを10枚見た人も、1枚見て帰った人も、同じ「1ページビュー」として記録される。

    Next.jsのようにページを読み込み直さないサイトに計測を入れると、初期状態ではこうなります。数字は出ているのに、実態とまったく合っていません。今回はGoogleタグマネージャー(GTM)を挟んで、この問題を解いた記録です。

    この記事で扱うこと

    GTMを経由させる理由、コンテナとタグとトリガーの関係、そしてページ内遷移を計測に伝える仕組み。最後に、二重計上を防ぐための設定をひとつ入れています。ここが今回いちばん判断を要した部分でした。

    なぜ計測タグを直接貼らないのか

    GA4は、測定IDを含む数行をサイトに貼れば動きます。GTMを挟むと手順が増えます。それでも挟む理由は、ひとつだけです。

    計測の内容は必ず後から変わるからです。

    今はページビューだけで十分でも、そのうち「どのリンクが押されたか」「記事をどこまで読んだか」「連絡先を見た人は何人か」を知りたくなります。タグを直接書いていると、そのたびにコードを直し、ビルドし、デプロイすることになります。

    タグを直接貼る GTMを挟む
    最初の設置 数行貼るだけ コンテナ作成+タグ設定
    計測を1つ足す コード修正 → ビルド → デプロイ GTMの画面だけ
    設定を戻す コードを戻して再デプロイ 前のバージョンを公開し直す
    変更でサイトが壊れる可能性 ある ない

    最後の行が本質です。計測を変えるたびにデプロイするということは、計測の都合でサイトが壊れる機会を作っているということでもあります。本来関係のない2つのことが、同じ操作に結びついています。

    GTMを入れると、この2つが切り離せます。コードを触らずに計測を変えられる——これがGTMを挟む唯一にして最大の理由です。

    コンテナは既存アカウントの下に作った

    GTMもGA4と同じく入れ子構造で、アカウントの下にコンテナがあります。

    GTMの階層
    アカウント(ポートフォリオ)
      └ コンテナ(www.kisaku.site)   ← サイト1つに対して1つ
          ├ タグ      : 何を送るか
          ├ トリガー  : いつ送るか
          └ 変数      : 何の値を送るか

    今回は新しいアカウントを作らず、すでにあった「ポートフォリオ」アカウントの下にコンテナを追加しました。個人で管理するサイトが増えたとき、アカウントを分けるとどこに何があるか分からなくなるからです。同じ管理単位に並べておけば、一覧で見つかります。

    アカウントを分けるべきなのは、アクセス権を分けたいときだけです。アカウント単位でユーザーを招待するので、他人に片方だけ渡したい場合は分ける必要があります。逆にそういう予定がないなら、分ける理由はありません。

    作成すると GTM- から始まるコンテナIDが発行されます。これがサイト側に埋め込む値です。測定IDと同じで、HTMLに出力されるので秘密ではありません。

    タグとトリガーという考え方

    GTMの設定は、この2つの組み合わせで表されます。

    用語 意味 今回の例
    タグ 何を送るか GA4にページビューを送る
    トリガー いつ送るか ページ内で移動したとき

    「いつ」と「何を」を分けて持つのがGTMの設計です。同じタグを複数のトリガーで使い回せますし、同じトリガーで複数のタグを一斉に発火させることもできます。

    今回作ったのは、タグ2つとトリガー1つです。

    作成した構成
    タグ1 : GA4 - Google タグ
            種類     Google タグ
            タグID   G-872K3PN503
            トリガー Initialization - All Pages
            設定     send_page_view = false
    
    タグ2 : GA4 - page_view (SPA)
            種類       GA4 イベント
            イベント名 page_view
            トリガー   CE - spa_page_view
    
    トリガー : CE - spa_page_view
            種類       カスタム イベント
            イベント名 spa_page_view

    タグ1がGA4本体の読み込み、タグ2がページビューの送信です。役割が分かれているところが今回の要点になります。

    ページ内の移動を計測に伝える

    ここからが本題です。

    Next.jsはリンクを押してもページ全体を再読み込みしません。画面の中身だけを差し替えて、アドレスバーを書き換えます。ブラウザから見れば「同じページのまま」なので、計測タグは何も気づきません。

    そこで、アドレスが変わったことを自分で検知して知らせる部品をサイト側に置きました。

    RouteChangeTracker.tsx(要点)
    const pathname = usePathname();
    const searchParams = useSearchParams();
    
    useEffect(() => {
      if (!window.dataLayer) return;
    
      const query = searchParams.toString();
      const pagePath = query ? pathname + "?" + query : pathname;
    
      window.dataLayer.push({
        event: "spa_page_view",
        page_path: pagePath,
        page_title: document.title,
      });
    }, [pathname, searchParams]);
    
    return null;

    dataLayer はGTMが監視している配列です。ここに event を含むオブジェクトを積むと、GTM側で「カスタムイベント」として拾えます。サイトとGTMの間の唯一の連絡口だと思えば分かりやすいです。

    この部品は画面に何も表示しません。return null しています。副作用だけを持つコンポーネントです。

    Suspenseで包む必要がある

    useSearchParams を使う部品は、Reactの Suspense で囲まないとビルド時に警告が出ます。囲まないと、そのページ全体が「事前生成できない」扱いになり、訪問のたびにサーバーで組み立てられるようになります。速度改善のために事前生成を入れたばかりだったので、ここは見落とせませんでした。計測を足したせいでサイトが遅くなる、というのは本末転倒です。

    GTM側では、このイベント名を待ち受けるトリガーを作ります。トリガーの種類は「カスタム イベント」、イベント名に spa_page_view と入れるだけです。あとはそのトリガーで、イベント名 page_view のGA4イベントタグを発火させます。

    二重計上を止める

    ここで問題が起きます。

    Googleタグは、読み込まれた時点で自動的にページビューを1回送ります。一方、上の RouteChangeTracker は useEffect で動くので、最初に表示されたときにも一度発火します。

    場面 Googleタグの自動送信 spa_page_view 記録されるPV
    最初にページを開く 1 1 2
    リンクで次のページへ 0 1 1

    最初の1ページだけが二重に数えられます。すべてのページが2倍になるならまだ気づけますが、入口のページだけが多く見えるという歪み方をします。これは気づきにくく、しかも「トップページが人気」という誤った結論を導きます。

    解決方法は2つ考えられました。

    案 やること 問題
    初回だけ送らない フロント側で「初回マウントかどうか」を判定して除外する 判定の条件がコードの中に隠れる。読み込み直しとの区別も難しい
    自動送信を止める Googleタグの send_page_view を false にする 設定を1つ追加するだけ

    後者を選びました。Googleタグの「設定」→「構成パラメータ」に、以下を追加しています。

    Googleタグの構成パラメータ
    send_page_view : false

    これでGoogleタグはGA4を読み込むだけになり、ページビューを送るのは spa_page_view 経由の1本だけになります。

    発生源をひとつにする

    同じ数字が2か所から出てくる状態は、どちらが正しいかを常に考え続けることになります。片方を止めて発生源を1本にするほうが、計測を足すたびに悩まずに済みます。設定の数を減らすことより、考える回数を減らすことのほうが効きます。

    IDが無いときは何も出力しない

    サイト側の読み込み部分には、もうひとつ仕掛けを入れています。

    Analytics.tsx(要点)
    const GTM_ID = process.env.NEXT_PUBLIC_GTM_ID;
    
    export default function Analytics() {
      if (!GTM_ID) return null;
      // ... GTMの読み込み
    }

    環境変数が設定されていなければ、何も出力しません。これで2つのことが同時に解決します。

    ひとつは、IDを取得する前にデプロイしても壊れないこと。コードを先に入れておいて、あとから設定を足せます。もうひとつは、手元の開発中の操作が本番の数値に混ざらないことです。自分でページを何度も開き直す作業をしている最中に計測が動いていると、その分がそのまま記録されます。

    公開を忘れると何も起きない

    GTMには最後にひとつ落とし穴があります。

    タグを作って保存しただけでは、サイトには反映されません。右上の「公開」を押してはじめて配信されます。

    作業中の状態を「ワークスペース」と呼び、公開すると「バージョン」として固定されます。バージョンには名前と説明を付けられるので、今回は次のように残しました。

    公開したバージョン
    名前 : v1 GA4初期設定
    説明 : Googleタグ(G-872K3PN503、send_page_view=false)
           +SPA用GA4イベントpage_view(カスタムイベントspa_page_view)

    ここを空欄で公開してしまうと、半年後に見たときに何をしたバージョンなのか分かりません。過去のバージョンに戻せるのがGTMの利点ですが、戻す先が分からなければ意味がありません。説明を書くのは、その利点を使えるようにしておく作業です。

    今回の学び

    まず、速いサイトほど計測が壊れるということ。ページを読み込み直さない仕組みは体験としては良いのですが、計測の前提を静かに崩します。表示が速いことと、計測が正しいことは、別々に確かめる必要がありました。

    次に、数字は「出ている」と「合っている」が違うこと。タグを貼ればリアルタイムに自分のアクセスが出るので、動いているように見えます。しかし中身は初回だけ2倍で、遷移は数えていない状態でした。動作確認の基準を「表示されるか」ではなく「意図した回数か」に置く必要があります。

    そして、同じことを2か所で管理しないこと。二重計上の対処で、フロント側を賢くする案とGTM側で止める案がありました。前者は判定条件がコードの中に隠れます。設定として見える場所に置いたほうが、あとから読み解けると判断しました。

  • GA4を入れる前に決めたこと

    GA4を入れる前に決めたこと

    サイトを作って公開しました。ここまで、誰がどのページを見ているのかを一度も見ていません。

    見なくても作れてしまうのが、個人サイトの怖いところです。手応えの代わりになるものが何もないまま、次に何を直すかを勘で決めることになります。今回はその状態を終わらせるために、Googleアナリティクス4(GA4)を入れました。

    この記事で扱うこと

    GA4のプロパティとデータストリームの作り方、そのときに聞かれる設問への答え方、測定IDの意味。そして「計測を入れる前に決めておくべきこと」。手順そのものより、なぜその選択をしたかを中心に書きます。

    計測を入れる前に決めたこと

    最初に、数字を見て何をするつもりなのかを決めました。これを決めずに入れると、毎日ダッシュボードを眺めて満足するだけの習慣ができます。

    このサイトで知りたいことは3つでした。

    知りたいこと それが分かると何ができるか
    どの記事が読まれているか 次に書く記事の題材を、勘ではなく実績で決められる
    どこから来ているか 検索経由が少なければ、書き方ではなく見つけられ方に問題があると分かる
    途中で離脱していないか 記事の構成や長さを見直す根拠になる

    逆に、追わないと決めたものもあります。ユーザー数の絶対値です。個人のポートフォリオで、月間の訪問者数を増やすこと自体を目標にすると、内容と関係のない集客に手が伸びます。ここでは「読まれ方の質」だけを見ることにしました。

    アカウントとプロパティの関係

    GA4は入れ子構造になっていて、最初にここでつまずきます。

    GA4の階層
    アカウント(KiSAKU)
      └ プロパティ(kisaku.site)        ← データが貯まる単位。レポートはここで見る
          └ データストリーム(ウェブ)    ← 計測タグを置く対象。1つのURL群に対応する

    覚え方としては、プロパティが「1つのサービス」、データストリームが「その入り口」です。ウェブサイトとiOSアプリを両方持っているなら、プロパティは1つでストリームが2つになります。同じサービスの利用者を、端末ごとに分断せずに見られます。

    逆に、まったく別のサイトを同じプロパティに入れてはいけません。レポートが混ざって、どちらの数字も読めなくなります。今回は kisaku.site 専用のプロパティを新しく作りました。

    作成時に聞かれること

    プロパティを作ると、いくつか設問が出ます。あとから変更できるものと、しにくいものがあります。

    項目 設定した値 あとから変えられるか
    タイムゾーン 日本(GMT+09:00) 変えられるが、過去のデータは変換されない
    通貨 日本円 同上
    業種 コンピュータ、電化製品 いつでも変更可
    ビジネスの規模 小規模 いつでも変更可
    ビジネス目標 トラフィックの分析/エンゲージメントと維持率の把握 いつでも変更可
    タイムゾーンだけは最初に正しく

    初期値はアメリカの時間帯です。そのままにすると「日付の変わり目」がずれ、日別レポートが実感と合わなくなります。しかも後から直しても、すでに記録されたデータは古い時間帯のままです。最初の1回しか正しく設定する機会がありません。

    業種と規模は、初期レポートの構成を決めるためのものです。間違えても実害はほとんどありません。ビジネス目標も同様で、選んだ内容に応じて左メニューの項目が変わるだけです。ここで悩んで手が止まるくらいなら、近いものを選んで先に進むべきです。

    データストリームと測定ID

    プロパティを作ると、続けてデータストリームの作成を求められます。ここで入力するのは計測対象のURLです。

    設定した内容
    ストリーム名 : kisaku.site ウェブ
    ウェブサイトのURL : https://www.kisaku.site
    ストリームID : 15464874599
    測定ID : G-872K3PN503

    URLに www を付けたのは、このサイトの正規のアドレスが www.kisaku.site だからです。kisaku.site(wwwなし)にアクセスすると、www 付きへ転送されます。転送先のほうを登録するのが正しい選び方です。

    ここで出てくる G- から始まる文字列が測定IDです。これがサイトとGA4のプロパティを結びつける鍵になります。

    測定IDは秘密ではない

    この値はサイトのHTMLに出力され、ブラウザの開発者ツールで誰でも見られます。隠す必要はありませんし、隠せません。パスワードやAPIキーと同じ扱いをする必要はない、と最初に理解しておくと迷わずに済みます。

    タグを直接貼らなかった理由

    GA4は、測定IDを含む数行のスクリプトをサイトに貼れば動きます。それがいちばん短い道です。

    しかし今回は貼りませんでした。代わりにGoogleタグマネージャー(GTM)を経由させています。

    理由は、計測の内容は必ず後から変わるからです。今はページビューだけで十分でも、そのうち「どのリンクが押されたか」「記事をどこまで読んだか」を知りたくなります。タグを直接書いていると、そのたびにコードを直し、ビルドし、デプロイすることになります。

    デプロイが必要ということは、計測を変えるたびにサイトが壊れる可能性が生まれるということでもあります。本質的に関係のない2つのことが、同じ操作に結びついてしまっています。

    GTMを挟むと、この2つが切り離せます。計測の変更はGTMの画面だけで完結し、サイトのコードには一切触れません。GTMの構成は次の記事で扱います。

    なぜ「入れただけ」では不十分なのか

    ひとつだけ、先に書いておきます。

    このサイトはNext.jsで作られていて、リンクを押してもページ全体を読み込み直しません。画面の中身だけを差し替えて、アドレスバーの表示を書き換えます。表示は速くなりますが、計測の側から見ると「1回読み込まれたきり、ずっと同じページを見ている」ようにしか見えません。

    この状態で放置すると、記事を10本読んだ人も、トップページだけ見て帰った人も、同じ「1ページビュー」として記録されます。

    入れて安心するのがいちばん危ない

    タグを貼ってリアルタイムに自分のアクセスが出れば、動いているように見えます。しかしこの状態のGA4は、実態とかけ離れた数字を、正しい顔で出し続けます。数字が無いより、間違った数字があるほうが判断を誤ります。

    この対処が、今回いちばん手間のかかった部分です。次の記事で扱います。

    今回の学び

    まず、計測は「入れる」より「何を見るか決める」ほうが先だということ。ツールは何でも記録してくれますが、見る対象を決めていないと、記録されたものを眺めるだけで終わります。追わないものを決めておくのも同じくらい大事でした。

    次に、最初の1回しか設定できない項目があること。タイムゾーンがそれでした。あとで直せる項目とそうでない項目が同じ画面に並んでいて、見た目からは区別できません。「変えられるか」を先に調べるだけで、後戻りの多くは防げます。

    そして、短い道が最善とは限らないこと。タグを直接貼れば5分で終わりました。GTMを挟んだぶん手順は増えましたが、この先の変更が全部コードの外側で済むようになります。一度きりの作業を短くするか、繰り返す作業を短くするかという選択でした。