カテゴリー: 技術系

  • よく使うGitコマンドと、それが何をしているか

    よく使うGitコマンドと、それが何をしているか

    Gitのコマンドは、覚えるより「いま何がどこにあるか」を掴むほうが早いです。

    この記事では、実際に毎日使っているコマンドを、何をしているかの説明つきで並べます。順番は使う頻度ではなく、理解の順番にしました。

    この記事で扱うこと

    Gitが管理している3つの場所という考え方と、それに沿ったコマンドの整理。特に「戻す」系は似たコマンドが多く、取り違えると作業が消えます。実際に消した経験も含めて書きます。

    まず、3つの場所を知る

    Gitのコマンドが分かりにくいのは、ファイルが3つの場所に同時に存在するからです。ここを掴むと、大半のコマンドは「どこからどこへ動かすか」で説明がつきます。

    場所 何があるか 失うとどうなるか
    作業ツリー いまエディタで見ているファイル 戻せない
    ステージ 次のコミットに含めると決めたもの 作業ツリーから入れ直せる
    履歴 コミット済みのもの だいたい取り戻せる

    右の列が重要です。コミットしていないものだけが、本当に消えます。逆に言えば、コミットさえしてあれば、たいていの失敗は取り返せます。

    流れ
    作業ツリー  --git add-->  ステージ  --git commit-->  履歴
       (編集中)              (準備中)           (記録済み)

    状態を見る

    作業の8割はこれです。何かする前に、いまどうなっているかを見ます。

    git status –short
     M frontend/src/app/page.tsx      ← 変更あり(未ステージ)
    M  frontend/src/lib/data.ts       ← 変更あり(ステージ済み)
    ?? frontend/src/lib/new-file.ts   ← 新規(Gitがまだ知らない)

    左の2文字が、ステージと作業ツリーの状態を表します。--short を付けると1ファイル1行になり、ファイル数を数えられます。これが後で効きます。

    git log –oneline -5
    c5ce23e Merge pull request #11 from kisaku39/feat/next-16
    29aa412 chore(deps): Next.js 16 / React 19 へ移行
    ba625e3 Merge pull request #10 from ...

    直近5件の履歴。--oneline が無いと1コミットが5行になり、画面が埋まります。

    変更内容を見る
    git diff              # 作業ツリーの変更(未ステージ)
    git diff --staged     # ステージ済みの変更
    git diff --stat       # ファイル名と増減行数だけ

    --stat は、変更が意図した範囲に収まっているかを一目で見るときに使います。

    記録する

    基本の3つ
    git add ファイル名        # 指定したものをステージへ
    git add -A               # 変更・新規・削除をすべてステージへ
    git commit -m "説明"      # ステージの中身を履歴へ記録

    git add -A は便利ですが、意図しないファイルまで巻き込みます。実行前に git status --short で並んでいるものを確認する習慣にしておくと安全です。

    ファイル数を数える

    私は一度、5ファイル直したはずのコミットに2ファイルしか入っていない状態でpushしました。ビルドも通り、デプロイも成功し、何も直っていませんでした。git status --short の行数を数えるだけで、pushする前に気づけた失敗です。

    直前のコミットをやり直す
    git commit --amend -m "新しい説明"

    直前のコミットを作り直します。メッセージの誤字直しや、入れ忘れたファイルの追加に使います。push済みのコミットには使わないでください。履歴が別物になり、他の人の手元と食い違います。

    戻す ── ここが一番危ない

    似た名前のコマンドが複数あり、取り違えると作業が消えます。用途で分けて覚えるのが安全です。

    やりたいこと コマンド 危険度
    ステージから降ろす(編集は残す) git restore --staged ファイル 安全
    ファイルの編集を捨てる git restore ファイル 戻せない
    コミットを打ち消す新しいコミットを作る git revert コミットID 安全
    履歴を巻き戻す(編集は残す) git reset --soft コミットID 注意
    履歴を巻き戻して全部捨てる git reset --hard 非常に危険

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

    git reset --hard は、直前の操作を取り消したいときに手が伸びやすいコマンドです。しかし実際にやることは違います。

    git reset –hard が消すもの
    コミット済みの変更   → 履歴に残っているので取り戻せる
    未コミットの編集     → 消える。取り戻す手段は無い

    履歴に無いものは、履歴から復元できません。当たり前ですが、この当たり前が事故の形になります。私はこれで、書き換えたばかりの5ファイルを失いました。

    状態を整えたいだけなら stash

    「一旦きれいにしたい」「ブランチを切り替えたい」という理由でresetを使いたくなったら、その用途は git stash のほうです。捨てずに退避してくれます。

    revert と reset の違い

    どちらも「戻す」ですが、履歴の扱いが逆です。

    同じ「戻す」でも
    reset  … 履歴そのものを巻き戻す(無かったことにする)
    revert … 「打ち消す」コミットを新しく積む(戻した記録が残る)

    すでにpushしたものを戻すときは、必ずrevertです。resetで消すと、他の人の手元にある履歴と食い違い、収拾がつかなくなります。

    退避する

    git stash
    git stash              # 変更を退避して、作業ツリーをきれいにする
    git stash -u           # 未追跡(新規)ファイルも一緒に退避
    git stash list         # 退避したものの一覧
    git stash pop          # 最後に退避したものを戻す
    git stash drop         # 退避したものを捨てる

    -u を付ける場面が意外と多いです。既定のstashは、Gitがまだ知らない新規ファイルを退避しません。そのままpullすると「未追跡のファイルを上書きすることになるので中断する」と止められます。

    stash は名前を付けられる

    git stash push -m "pull前の退避" のようにメモを残せます。stashは溜まると何が何だか分からなくなるので、いつ・なぜ退避したかを書いておくと後で助かります。

    ブランチを扱う

    よく使うもの
    git branch                    # ブランチ一覧(* が現在地)
    git branch --show-current     # 現在のブランチ名だけ
    git checkout -b feat/xxx      # 新しいブランチを作って移動
    git checkout main             # 既存のブランチへ移動
    git branch -d feat/xxx        # ブランチを削除

    git branch -d はマージ済みのブランチしか消しません。未マージの変更が残っていれば拒否してくれます。大文字の -D は確認なしで消すので、日常では使わないほうが安全です。

    なぜブランチを切るのか

    本番に直接入れると、壊れていた場合に「戻す」しか選択肢がありません。ブランチで作業すれば、本番を触らないまま動作確認できます。デプロイ先によっては、ブランチごとに確認用のURLを作ってくれます。確認の機会を1回増やすだけで、事故の大きさが変わります。

    共有する

    やり取りの3つ
    git fetch      # リモートの状態を取ってくる(作業ツリーは変えない)
    git pull       # fetch + 自分のブランチに取り込む
    git push       # 自分のコミットをリモートへ送る

    git push が拒否されることがあります。

    よくある拒否
    ! [rejected]  main -> main (non-fast-forward)
    hint: Updates were rejected because the tip of your current
    hint: branch is behind its remote counterpart.

    これは正しい動作です。手元がリモートより遅れている状態でpushすると、リモート側の新しいコミットが消えます。Gitはそれを防いでいます。

    対処は「先に取り込む」です。git pull してから、あらためてpushします。ここで --force を使うと、止めてくれた意味が無くなります。

    困ったときの2つ

    操作の履歴をたどる ── reflog

    git reflog
    c5ce23e HEAD@{0}: commit: ...
    682faf3 HEAD@{1}: reset: moving to HEAD    ← ここで何かした
    682faf3 HEAD@{2}: pull: Fast-forward

    git log がコミットの履歴なのに対し、reflog は「自分がGitに何をしたか」の履歴です。checkout、reset、pull、commit——すべて記録されています。

    「変更が消えた」「いつのまにか違うブランチにいる」というときは、まずこれを見ます。何が起きたかを推測せずに済みます。私も、消えた原因がresetだったことをこれで特定しました。

    「別のプロセスが動作中」と言われる ── index.lock

    こんなエラー
    fatal: Unable to create '.git/index.lock': File exists.
    Another git process seems to be running in this repository.

    Gitは操作のたびに .git/index.lock という目印のファイルを作り、終わったら消します。何らかの理由で消し損ねると、このファイルが残り続けます。

    そしてロックが残っている間、Gitはすべての操作を拒否します。本当に別のプロセスが動いている可能性があるからです。

    対処
    # 本当に他でGitを動かしていないことを確認してから
    rm -f .git/index.lock

    このファイルは0バイトで、中身がありません。Gitのデータは一切入っていない、単なる目印です。他でGitを動かしていないことさえ確かなら、消して問題ありません。

    同じリポジトリを複数の場所から触らない

    私がこれを踏んだのは、同じリポジトリに2つの経路から触っていたためでした。片方に書き込み権限が足りず、ロックを作れたのに消せませんでした。.git は排他制御を前提に作られているので、複数の経路から同時に触る構成は避けるべきです。

    1日の流れに並べると

    典型的な作業
    git status --short            # 汚れていないか確認
    git pull                      # 最新を取り込む
    git checkout -b feat/xxx      # 作業用のブランチへ
    
    (編集する)
    
    git status --short            # ファイル数を数える
    git diff                      # 中身を確認
    git add -A
    git commit -m "..."
    git push -u origin feat/xxx   # ブランチのままpush
    
    (確認用URLで動作確認)
    (問題なければマージ)
    
    git checkout main
    git pull
    git branch -d feat/xxx        # 役目を終えたブランチを削除

    この中でいちばん省かれやすいのが、最初と真ん中の git status --short です。そして今日書いた失敗は、どちらもここを飛ばしたことが原因でした。

    まとめ

    まず、コミットしていないものだけが本当に消えること。逆に言えば、こまめにコミットしておけば、たいていの失敗は取り返せます。危ない作業の前にコミットを1つ挟むのは、いちばん安いお守りです。

    次に、reset は取り消しではなく破棄だということ。状態を整えたいだけなら stash を使います。用途で選び分けるだけで、事故の大半は避けられます。

    そして、何かする前に状態を見ること。git status --short と git diff --stat の2つを挟むだけで、「意図しないものが混ざっている」「意図したものが入っていない」の両方に気づけます。

    最後に、止められたときは理由があること。pushの拒否も、ロックによる中断も、Gitが壊れないように守っている動作です。--force や強制削除で押し通す前に、なぜ止まったのかを読むほうが、結果的に速いです。

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

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

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

    それでも、直したかったものは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つで走査対象になっていました。意図は、コードのどこかに具体的な形で表れていないと守られません。

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

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

    ヘッドレス構成の作り方を調べると、だいたい「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か所すべてで同じホスト名に揃える必要がありました。

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