投稿者: sakurai_dev

  • サーバーは、要らないはずだった

    サーバーは、要らないはずだった

    ヘッドレス構成の作り方を調べると、だいたい「Next.jsをVercelに置く」と出てきます。無料枠で動きます。

    それを読んで、サーバーは借りなくていいのだと思っていました。実際には借りました。何を勘違いしていたのか、そして借りるときに何を見て決めたのかを書いておきます。

    この記事で扱うこと

    WordPressをVercelに置けない理由、無料の選択肢を外した判断、そして「安いほうで足りる」と結論した根拠です。

    ヘッドレスは、置き場所が2つ要る

    ヘッドレスという言葉は「WordPressの見た目を切り離す」という意味です。切り離すだけで、WordPress自体が消えるわけではありません。

    部分 役割 置き場所
    Next.js 読者が見る画面を作る Vercel
    WordPress 記事を書いて保管する レンタルサーバー

    調べていた記事の多くはNext.js側の話をしていて、WordPress側は「すでにある前提」で書かれていました。既存サイトをヘッドレス化する人が多いので、当然といえば当然です。これから作る人だけが、置き場所の話に気づかないまま進みます。

    VercelにWordPressは置けない

    置けないかどうか、最初に確かめました。結論は置けません。

    WordPressはPHPというプログラムで動き、記事はMySQLというデータベースに入ります。Vercelはどちらも常時動かす場所ではありません。仕組みが違うので、無理に寄せる話ではありませんでした。

    無料で済ませる道を、ひととおり見た

    お金をかけずに済むなら、そのほうがいい。いくつか検討して、どれも外しました。

    外した理由は共通していて、止まると記事が書けなくなることでした。

    無料の枠には、一定時間アクセスが無いと停止するものがあります。読者が見るのはVercel側なので、CMSに人が来ない時間は長くなります。書こうと思ったときに毎回起動を待つのは、書く回数が減る方向に効きます。

    続けられるかどうかで選ぶ

    費用を抑えるほど、手間か不便さのどちらかに移ります。移った先が「書くたびに発生する手間」だと、書くこと自体が減ります。

    月に数百円で消える手間なら、消したほうが結果的に安いと判断しました。

    何が要らなかったか

    ここが今回いちばん効いた整理でした。

    ふつうWordPressをサーバーに置くときは、表示速度と同時アクセス数を気にします。読者が直接そこへ来るからです。

    ヘッドレスだと、読者はCMSに一度も来ません。来るのは自分と、ページを組み立てるときのNext.jsだけです。

    そうすると、こうなります。

    ふつうのWordPress ヘッドレスのCMS側
    表示速度が読者体験に直結 自分しか見ないので、多少遅くても困らない
    アクセス集中に耐える必要がある 同時に使うのは1人
    止まると全部見えなくなる 止まっても、公開済みのページは見え続ける

    いちばん下が大きい違いです。ページはあらかじめ組み立てて配っているので、CMSが止まっている間も、読む側には何も起きません。影響が出るのは新しい記事を出すときだけです。

    つまり、上位プランを選ぶ理由がほとんどありません。いちばん安い共用プランで足ります。

    逆に、確認したこと

    安ければ何でもいいわけではなく、これだけは満たしていないと動きません。

    ひとつめはPHPのバージョンを自分で選べること。古いまま固定されていると、必要な機能が動かないことがあります。

    ふたつめはサブドメインを独自ドメインで作れて、SSLが付くこと。表側とCMSを分けるので、cms. のようなサブドメインを1つ切ります。ここに鍵マークが付かないと、ブラウザ側で通信が弾かれます。

    みっつめはデータベースが使えること。記事の本体はここに入ります。個数が1つでも足ります。

    確認は、契約前に

    この3つはどれも、契約後に「入っていなかった」と分かるものです。仕様のページに必ず書いてあるので、申し込み画面に進む前に見ておくと、あとで移す手間がなくなります。

    結局どこにしたか

    上の3点(PHPのバージョンを選べる・サブドメインにSSLが付く・データベースが使える)を満たしていて、いちばん安い共用プランで足りました。上位プランを検討する理由が、ヘッドレスだと出てきません。

    ドメインと同じ会社にまとめたのは、請求と管理画面を1つに収めたかったからです。ただしこれには落とし穴があって、DNSの設定画面が2か所に増えます。片方は参照されないのにエラーも出ません。ここで丸一日使いました。

    お名前.comのレンタルサーバー

    実際に困ったこと

    安いプランで足りる、とは書きましたが、共用サーバー特有の引っかかりはありました。

    ひとつはセキュリティの仕組みによる誤検知です。記事の本文にHTMLのタグが多く含まれていると、攻撃だと判定されて保存が弾かれることがありました。これは別記事に書いています。

    もうひとつは予約投稿の時刻がずれることです。WordPressの予約投稿は、時計ではなくアクセスをきっかけに動きます。誰も来ないCMSでは、指定した時刻をしばらく過ぎてから公開されます。

    どちらも、ヘッドレスにしたから起きたというより、「人が来ないサーバー」であることの副作用でした。安さの問題ではありません。

    今回の学び

    まず、解説記事は前提を書かないこと。「Vercelに置く」という説明は正しいのですが、それはNext.js側の話でした。書いた人にとって当たり前すぎる前提ほど、文章から抜け落ちます。

    次に、要件は「誰が使うか」から決まること。同じWordPressでも、読者が来るのと自分しか来ないのとでは、必要なものがまったく違いました。製品の比較表を見る前に、自分の使い方を書き出すほうが早いです。

    そして、費用を削った先がどこに移るかを見ること。無料にすると、たいてい手間か待ち時間に移ります。それが「書くたびに発生する手間」なら、削らないほうが続きます。

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

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

    脆弱性の警告が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の追従目次は本文の面積を削りませんが、スマホでは削ります。同じ機能でも、払う代償が違います。

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

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

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

  • GTMの設定は誰でも読める

    GTMの設定は誰でも読める

    GTMのコンテナIDをURLに打ち込むと、その設定が誰でも読めます。

    開いてみる
    https://www.googletagmanager.com/gtm.js?id=GTM-XXXXXXX

    返ってくるのはJavaScriptですが、その中にどんなタグが、どんな条件で発火するかが全部入っています。ログインは要りません。これは不具合ではなく、そうでなければ動かない仕組みです。

    この記事で扱うこと

    なぜコンテナの中身が公開されているのか、そこから実際に何が読めるのか、そして「だから何をしてはいけないか」。計測IDが公開情報であることと、その先で何が見えるかは、分けて考える必要があります。

    公開されていないと動かない

    理由は考えてみれば当たり前です。

    GTMは、閲覧者のブラウザの中で動きます。ブラウザが設定を読み込めなければ、タグを発火させようがありません。そしてブラウザが読めるということは、人間も読めるということです。

    置き場所 誰が読めるか
    GTMのコンテナ設定 閲覧者のブラウザ 誰でも
    サーバー側の処理 自分のサーバー 自分だけ

    ブラウザで動くものに秘密は持てません。これはGTMに限った話ではなく、フロントエンドに置いたものはすべて公開されているという一般則の一例です。JavaScriptを難読化しても、通信を暗号化しても、最終的にブラウザが解釈する以上は読めます。

    何が読めるのか

    このサイトのコンテナを例にすると、次のようなことが分かります。

    読めること この例では
    使っている計測ツール GA4(測定ID付き)
    タグの名前 GA4 – article_read など
    発火の条件 カスタムイベント article_read
    送っているパラメータ名 article_slug / article_title
    データレイヤー変数の名前 同上

    つまり「このサイトは何を測っているか」が丸ごと見えます。競合他社のサイトを調べる手段としても使われますし、それ自体は行儀の悪いことではありません。

    逆に言えば、あなたのサイトも同じように見られています。計測の設計を秘密にしておくことはできない、と最初から考えておくのが正しい前提です。

    だから、書いてはいけないもの

    ここが実務上いちばん大事な帰結です。

    カスタムHTMLタグに秘密の値を書かない

    GTMには「カスタムHTML」というタグの種類があり、任意のJavaScriptを書けます。便利なので、外部サービスのAPIキーやトークンを直接書いてしまう事故が起こります。それは公開されたテキストファイルに書くのと同じです。コンテナを公開した瞬間、世界中から読めます。

    同じ理由で、次のようなものも置けません。

    社内向けの管理画面のURL、テスト環境のアドレス、まだ発表していないキャンペーンの名前。タグの名前や変数の名前にも情報が乗ります。「新製品X_LP_2026春」というタグ名を作れば、それも読めます。

    IDが公開なのと、悪用できるのは別

    「IDが見えるなら、誰かに偽のデータを送られるのでは」という心配が出ます。実際その通りで、GA4の測定IDが分かれば、第三者がデータを送ることは技術的にできます。

    ただしこれはIDを記事に書いたから起きるのではありません。サイトを開けば誰でも分かる以上、隠しようがないからです。

    心配 実際のところ
    設定を書き換えられる できない。読めるだけで、変更にはGTMアカウントの権限が要る
    偽のデータを送られる できる。ただしIDを隠しても防げない
    秘密の値を盗まれる 置かなければ盗まれない。ここだけが自分で決められる

    3行目だけが、こちらの判断でどうにでもなります。防げないことに対策を考えるより、防げることを確実にやるほうが効きます。

    偽データへの現実的な対処

    GA4にはホスト名で受信を制限する機能がありません。実務では、GTM側のトリガーに「ホスト名が自分のドメインのときだけ発火する」条件を足します。これはサイトを複製された場合に効きます。直接データを送りつけられるケースは、サーバー側で計測する構成にしない限り防げません。個人サイトでそこまでやる必要があるかは、別途考えるべきことです。

    公開前に自分で読んでみる

    実際に一度開いてみることを勧めます。自分のコンテナが何を晒しているかを、公開する前に知っておけます。

    確認の手順
    1. https://www.googletagmanager.com/gtm.js?id=(自分のコンテナID)を開く
    2. ページ内検索で、次のような語を探す
       - key / token / secret / password
       - 社内のドメイン名
       - 未発表の企画名

    何も出てこなければ問題ありません。見つかったら、公開を止めてそのタグを直します。一度公開したものは、消しても誰かの手元に残っている可能性があります。

    今回の学び

    まず、ブラウザで動くものに秘密は持てないということ。GTMのコンテナが公開されているのは設計の欠陥ではなく、そうでなければ動かないからです。「見えてしまう」ではなく「見えるように作られている」と理解すると、置いていいものと悪いものの線が引けます。

    次に、IDが公開なのと、悪用できるのは別の話だということ。読めることと書き換えられることは違います。混同すると、防げないことに時間を使い、防げることを見落とします。

    そして、名前にも情報が乗ること。値だけを気にして、タグ名や変数名を無防備にしてしまいがちです。読まれる前提で名前を付ける習慣は、持っておいて損がありません。

  • 推測させるのをやめる

    推測させるのをやめる

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

    今回入れたのは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本上限は、何のエラーも出さずに記事を落とし続けるところでした。出力されたものを数える、という確認をしなければ気づけませんでした。

  • 所有権をどう証明するか

    所有権をどう証明するか

    「このサイトはあなたのものですね」を、Googleはどうやって確かめているのか。

    Search Consoleにサイトを登録すると、所有権の証明を求められます。方法は5つ提示されますが、それぞれ証明している中身が違います。同じ「所有権」という言葉でくくられているせいで、そこが見えにくくなっています。

    この記事で扱うこと

    5つの確認方法が何を根拠にしているか、DNSのTXTレコードだけが別格である理由、ドメインプロパティでDNS確認しか認められていない理由。そして実際に登録するときの注意点。

    5つの方法は、証明の強さが違う

    Search Consoleが提示する確認方法を、「何ができる人だと証明しているか」で並べ替えるとこうなります。

    方法 証明していること
    Googleアナリティクス そのGA4プロパティを編集できる
    Googleタグマネージャー そのGTMコンテナを編集できる
    HTMLタグ そのページのHTMLを書き換えられる
    HTMLファイル そのサーバーにファイルを置ける
    DNSレコード そのドメインそのものを支配している

    上の4つは、いずれも「サイトの中身に手が届く」ことしか示していません。制作を請け負っている人でも、CMSの編集権限を持っている人でも通せます。

    DNSだけが違います。DNSレコードを書き換えられるということは、そのドメインをどのサーバーに向けるかを決められるということです。極端に言えば、サイトの中身を丸ごと別の場所に差し替えられます。所有者でなければ持てない権限です。

    だからドメインプロパティはDNSしか認めない

    Search Consoleのドメインプロパティは、www の有無もサブドメインもすべてまとめて扱います。cms.kisaku.site のような、まだ存在しないサブドメインまで対象に含みます。まだ無いサブドメインにファイルは置けません。だから「ドメイン全体の持ち主である」ことを、上の階層で一度に証明する必要があります。それができるのはDNSだけです。

    TXTレコードとは何か

    DNSは本来、名前をIPアドレスに変えるための仕組みです。レコードには種類があります。

    種類 用途
    A / AAAA 名前をIPアドレスに変換する
    CNAME 名前を別の名前に転送する
    MX メールの宛先サーバーを示す
    NS このドメインを管理するサーバーを示す
    TXT 任意の文字列を置ける(用途は決まっていない)

    TXTだけが異質です。「好きな文字を書いておける場所」として定義されていて、何に使うかは決められていません。

    この自由さが、あとから来た用途に次々と使われました。所有権の証明もそのひとつです。SPF(メールの送信元認証)も、DKIMも、TXTレコードの上に成り立っています。

    実際に登録した内容
    ホスト名   (空欄。管理画面によっては @ と表示される)
    種別       TXT
    値         google-site-verification=(発行された文字列)
    TTL        既定のまま

    ホスト名を空欄にするのは、ドメインそのもの(kisaku.site)に対して置くという意味です。ここに www と入れると www.kisaku.site のレコードになり、ドメインプロパティの確認には使えません。

    なぜこれで証明になるのか

    仕組みは単純です。

    確認の流れ
    1. Googleが、あなた専用のランダムな文字列を発行する
    2. あなたが、その文字列を kisaku.site のTXTに登録する
    3. Googleが、世界中から見えるDNSに問い合わせる
    4. 発行した文字列が返ってくれば、確認完了

    ポイントは3番です。GoogleはあなたのサーバーやCMSを見に行っていません。公開されているDNSに問い合わせているだけです。

    そしてDNSの内容を書き換えられるのは、ドメインを管理している人だけです。第三者があなたのドメインのTXTレコードに勝手な値を入れることはできません。「書き換えられた」という事実そのものが証明になっているわけです。

    パスワードを渡さずに証明する

    この方式のうまさは、Googleに何の権限も渡していないことです。サーバーのログイン情報も、CMSの管理権限も渡していません。「あなたにしかできない操作を実際にやってみせる」だけで済んでいます。所有権の証明としては、これがいちばん筋の通った形です。

    反映を自分で確かめる

    DNSの変更は、登録した瞬間に世界中へ伝わるわけではありません。管理画面で保存したあと、実際に公開されるまでに時間がかかります。

    「確認」を押して失敗したとき、原因が登録内容の間違いなのかまだ反映されていないだけなのかは、画面からは分かりません。ここで待ち続けるか設定を疑うかを間違えると、時間を大きく無駄にします。

    自分で問い合わせれば、すぐに区別できます。

    TXTレコードを直接見る
    dig kisaku.site TXT +short
    
    "v=spf1 include:spf21.gmoserver.jp ~all"
    "google-site-verification=..."

    ブラウザだけでも確認できます。https://dns.google/resolve?name=kisaku.site&type=TXT を開くと、Googleのリゾルバが見ている内容がJSONで返ってきます。Googleが実際に何を見ているかを、Googleに聞くので、Search Consoleの判定と食い違いません。

    最初に確認したとき、まだ見えていなかった

    実際、私は1回目の「確認」で失敗しました。設定を疑う前にDNSへ直接問い合わせたところ、返ってきたのはSPFの1件だけで、確認用の文字列はまだ公開されていませんでした。失敗の原因が「間違い」ではなく「待ち」だと分かったので、設定をいじり回さずに済みました。

    保存できたのに、公開されない

    ここからは、実際に私がはまった話です。

    管理画面でTXTレコードを追加し、「保存しました」と表示されました。ところが何時間経っても、DNSに問い合わせると返ってくるのはSPFの1件だけ。TTLは600秒——10分です。10分待てば消えるはずのキャッシュを、何時間も疑っていました。

    原因は、編集していた管理画面が、そのドメインの答えを持っていなかったことでした。

    ドメインの管理画面は、1つとは限らない

    ドメインを買った会社と、そのドメインの問い合わせに実際に答えるサーバーは、別々にできます。どちらが答えるかを決めているのがNSレコードです。

    誰が答えているのかを聞く
    dig kisaku.site NS +short
    
    dns01.gmoserver.jp.
    dns02.gmoserver.jp.

    返ってきたのはレンタルサーバー側のDNSでした。ドメインを買った会社の管理画面ではありません。

    できること 今回の状態
    ドメイン登録側の管理画面 NSレコードを決める ここを編集していた
    NSが指しているDNS 実際の答えを返す こちらが本番だった

    権限のない側の画面に書いた設定は、エラーにならずに保存され、そして誰にも参照されません。これが厄介なところです。間違いを教えてくれる仕組みが、どこにもありません。

    「保存できた」は「効いている」ではない

    管理画面の保存成功は、その画面のデータベースに書けたことしか意味しません。それが世界から参照されるかどうかは別の話です。DNSに限らず、設定が複数の場所に分かれている仕組みでは同じことが起こります。

    先に、答えている場所を特定する

    この失敗を避ける手順は単純です。レコードを追加する前に、NSを引く。

    作業前に確認する
    # 誰が答えるか
    dig kisaku.site NS +short
    
    # ブラウザだけでも見られる
    https://dns.google/resolve?name=kisaku.site&type=NS

    返ってきたサーバー名で、どこの管理画面を開けばいいかが分かります。*.gmoserver.jp ならレンタルサーバーのコントロールパネル、*.vercel-dns.com ならVercel、*.cloudflare.com ならCloudflareです。

    既存のレコードが、目印になる

    正しい画面かどうかは、開いた瞬間に判断できます。すでに動いているレコードが並んでいる画面が本番です。今回で言えば、AレコードとSPFとサブドメインのCNAMEが揃っている画面。逆に、実際のサイトが動いているのに空っぽの一覧が表示されたら、そこは参照されていない画面です。

    既存のレコードを消さない

    DNSの編集で、いちばん怖いのはこれです。

    TXTレコードには、すでにSPF(メールの送信元認証)が入っていることがよくあります。これを消すと、送ったメールが迷惑メール扱いされるようになります。サイトの表示には何の影響もないので、しばらく気づきません。

    TXTは複数あってよい

    「TXTはひとつしか置けない」と誤解して、既存のSPFを上書きしてしまう事故が起きます。同じホスト名に複数のTXTレコードを共存させられます。追加するのは1行だけで、既存の行はそのまま残します。

    もっと危険なのはAレコードやCNAMEです。ここを触るとサイト全体が見えなくなります。DNSは、間違えたときの影響がサイト全体に及ぶ数少ない設定です。作業前に現在の内容を控えておくと、戻せます。

    今回の学び

    まず、同じ「所有権の証明」でも、証明している中身が違うということ。ファイルやHTMLタグは「中身に手が届く」ことしか示しません。DNSだけが「ドメインの持ち主である」ことを示します。選択肢が並んでいると同じものに見えますが、強さが違います。

    次に、TXTレコードは「用途が決まっていない」から使われていること。所有権の証明もSPFも、DNSの設計時には想定されていない用途です。汎用の置き場所をひとつ用意しておいたことが、後から効いています。

    そして、失敗したときは「間違い」か「待ち」かを先に切り分けること。DNSは反映に時間がかかる仕組みなので、この2つが同じ見た目になります。切り分けずに設定をいじると、正しかったものまで壊します。

    さらに、「保存できた」は「効いている」ではないこと。今回いちばん時間を取られたのは、参照されていない管理画面に正しい値を書き続けていたことでした。書き込む前にNSを引いて、答えている場所を先に特定する。これだけで防げます。

    最後に、影響範囲の広い設定ほど、触る前に控えを取ること。DNSは間違いがサイト全体に及びます。しかもメール側の不具合は、しばらく誰も気づきません。

  • 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を使う方式は一瞬で終わりますが、計測をやめた瞬間に所有権も外れます。関係のないもの同士を結びつけない——遠回りに見えても、独立させておくほうが後で自由が利きます。

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