Next.jsのページネーションSEO ─ 2ページ目以降のSSR・絞り込み結果のnoindex・sitemap lastmodを個人開発サービスで直した話

当ページのリンクには広告が含まれています。
この記事の結論
ブラウザで見て正しく動いていても、検索エンジンが受け取る初期HTMLやsitemapは別物でした。2ページ目以降をサーバー側で描いて実リンクでつなぎ、任意の検索結果や個人別の一覧はnoindexで外し、更新日時は「中身が実際に変わった時刻」だけで進めるようにしました。3つとも、検索エンジンに見せる情報を実際の中身と一致させる修正でした。

お疲れ様です!IT業界で働くアライグマです!

個人開発している技術ニュースキュレーションサービス「DevPick」で、検索エンジン向けの情報が実際のページとずれている箇所を3つまとめて直しました。どれも画面を普通に使っているだけでは気づけない種類のずれです。

ページ送り、絞り込み結果、更新日時という別々の箇所の話ですが、原因の構造はよく似ていました。それぞれ何がずれていて、どう直したのかを順に書いていきます。

目次

きっかけは、本番の初期HTMLを未ログインで確かめたこと

2026年10月3日に、DevPickの本番環境を未ログインの状態で開き、検索エンジンが受け取るものを1つずつ確かめました。見たのは、JavaScriptが動く前の初期HTML、ページに付いているtitle・canonical・robotsメタ、そしてsitemapに書かれている日時です。同じ日に、Search ConsoleのURL検査APIで代表的なURLの登録状態も取得しています。

普段の動作確認はブラウザで画面を操作して行うので、JavaScriptが動いた「後」の姿しか見ていません。一方で検索エンジンのクローラーは、まずサーバーが返したHTMLを読みます。Googleはレンダリングもしますが、リンクとして辿るのは基本的に <a href> ですし、noindexやcanonicalは初期HTMLに書かれていないと確実には伝わりません。この2つの姿を並べて比べたところ、次の3つのずれが見つかりました。

  • トップの2ページ目のURLを開いても、初期HTMLには1ページ目と同じ記事が並んでいた
  • 任意の検索語の結果や「いいね」した記事の一覧が、公開トップと同じtitle・canonicalのまま、noindexなしで返っていた
  • sitemapの最終更新日時と、記事ページの構造化データにある更新日時が、どちらも「中身が更新された時刻」を表していなかった

修正は3件とも翌日の10月4日に、それぞれ別の変更として入れました。順番は、更新日時、2ページ目のサーバー側描画、noindexです。以降のH2も、話の流れが追いやすいようにページ送りから説明します。

IT女子 アラ美
画面で見て大丈夫なら大丈夫、って思っちゃうのよね。クローラー目線は盲点だわ。

ITアライグマ
そうなんです。人が見る姿とHTMLの姿を、並べて比べたのが効きました。

2ページ目のHTMLが1ページ目と同じだった ─ SSRとクロール可能なページ送り

本番で / と /?page=2 の初期HTMLを取り出し、記事へのリンクを比べると、10件すべてが完全に一致していました。ブラウザで /?page=2 を開けば、JavaScriptが動いたあとにちゃんと2ページ目の記事へ切り替わります。つまり、人が見ると正しい2ページ目なのに、サーバーが最初に返すHTMLは1ページ目のままでした。

原因はNext.jsのページ本体にありました。URLからページ番号は読み取っていたのに、サーバー側で記事を取得するときにその番号を使っておらず、常に先頭から取得していました。2ページ目以降の記事は、画面が動き出したあとにブラウザ側で取り直していたわけです。さらに、ページ送りの「前へ」「ページ番号」「次へ」はすべてボタンとクリック処理だけで作っていて、HTMLには次のページへの <a href> が1本もありませんでした。titleとcanonicalも全ページでトップと同じです。カテゴリ別・トピック別の一覧はURLごとのサーバー側描画と自己canonicalに対応済みだったので、トップだけが取り残されていた形です。

直した内容は次の4点で、同じ変更の中でまとめて入れました。

  • サーバー側で該当ページを取得する:絞り込みのない公開一覧では、ページ番号から取得の開始位置を計算して、2ページ目なら2ページ目の記事を初期HTMLに描く
  • ページ送りを実リンクにする:前へ・ページ番号・次へを <a href="/?page=N"> として描画する。1ページ目はクエリなしの / にそろえる
  • ページごとのtitleとcanonical:2ページ目以降は「(Nページ目)」を付けたtitleと、そのページ自身を指すcanonicalを返す
  • 中身のないページは索引に載せない:範囲外のページ番号や取得失敗で記事が0件のときは、noindex, followを返す

単純化すると、メタデータ側の判定はこういう形です。メタデータを作る処理とページ本体の両方が同じページの記事を必要とするので、Reactの cache で包んで、1回のリクエストでAPIを二重に呼ばないようにしています。

// 単純化した例
const getPublicFeedPage = cache(async (page: number) =>
  getInitialFeed(PAGE_SIZE, DEFAULT_FILTERS, { offset: (page - 1) * PAGE_SIZE }),
);

export async function generateMetadata({ searchParams }) {
  const page = parsePageParam(await searchParams);
  if (page <= 1) return baseMetadata;

  const feed = await getPublicFeedPage(page);
  if (!feed || feed.articles.length === 0) {
    // 中身の無いページを自己canonicalで主張しない
    return { ...baseMetadata, robots: { index: false, follow: true } };
  }
  return createPageMetadata({
    title: `${BASE_TITLE} (${page}ページ目)`,
    pathname: `/?page=${page}`,
  });
}

ページ送りをリンクにするとき気をつけたのは、使い勝手を変えないことです。DevPickのフィードは、読み込んだ記事を手元に積み上げて持ち、ページを切り替えても同じ画面のまま表示を差し替えます(ページをまたいでも並び順がずれないようにした仕組みはフィードのページ送りを作り直した話で書いています)。そこで、通常の左クリックはリンクの遷移を止めて、これまでどおり同じ画面の中で切り替えるようにしました。Ctrlキーを押しながらのクリックや中クリックはブラウザの既定動作に任せるので、新しいタブで特定のページを開くこともできます。取得中は、修飾キー付きのクリックも含めてどの操作でも遷移させません。

もう1つ、マージ前の自動レビューで指摘されて足したのが、ページ番号の上限です。2ページ目以降をサーバー側で取得するようになった結果、URLにとんでもなく大きなページ番号を書くだけで、巨大な開始位置での取得を起こせる状態になっていました。指摘は3回に分かれて届いています。最初はサーバー側の取得、次は上限を超えた番号がそのままブラウザ側へ渡って全件を読み進めてしまう経路、最後はブラウザの戻る・進むで復元する経路でした。入口ごとに直すと次の入口で漏れるので、最終的には、カテゴリ別・トピック別の一覧ですでに使っていたページ数の上限(10万)を、ページ番号を読み取る共通の関数に入れました。上限を超えた値は1ページ目として扱われ、サーバー側描画、初回の復元、戻る・進むのすべてが同じ判定を通ります。

IT女子 アラ美
ボタンで動いてたからリンクが無いなんて、言われるまで絶対気づかないわ。

ITアライグマ
操作感はそのままに、HTMLにだけ道を足す形にできたのが良かったです。

検索結果や「いいね」一覧が、トップと同じ顔で索引対象だった ─ noindex, follow

2つ目のずれはトップページのクエリにありました。DevPickのトップは、URLのクエリで表示を切り替えます。検索語、媒体、カテゴリ、既読の扱い、そして「いいね」「興味なし」「閲覧履歴」「あとで読む」といった個人別の表示モードです。共有やブラウザの戻る操作で同じ画面を復元できるように、こうした状態をURLに残しています。

本番で /?q=zzzznonexistent123 のような存在しない検索語と、/?mode=likes を未ログインで開くと、どちらもステータス200で記事は0件でした。それなのにrobotsメタにnoindexはなく、title・description・canonicalは通常のトップとまったく同じです。トップのメタデータが、検索条件や表示モードを見ない固定値になっていたためです。ダッシュボードや復旧用のページのような固定パスには、すでにnoindexを付けていました。ところが同じトップのURLの上で切り替わる私的な一覧や任意の検索結果には、方針そのものがありませんでした。

canonicalをトップに向けているから大丈夫、とは言えません。canonicalは「正規のURLはこちらです」という検索エンジンへのヒントで、内容の違うページを索引から外すことまでは保証しないからです。そこで、検索語・絞り込み・個人別モードのどれかを伴うトップは、初期HTMLのrobotsメタで noindex, follow を返すようにしました。

  • noindex:検索結果や個人別の一覧そのものは索引に載せない。検索からの流入は、既定条件のトップ・カテゴリ別・トピック別のページに集める
  • follow:一覧の中の記事リンクは辿ってもらってよいので、リンクの評価は止めない
  • robots.txtでは遮断しない:robots.txtでクロール自体を止めると、クローラーはページを読めず、noindexの指示にも気づけない

判定から外したものもあります。未知のモード名や不正な値、計測用のパラメータだけが付いたURLは、読み取りの段階で既定値に倒れるので、通常のトップと同じ扱いになって索引対象のまま残ります。RSSの記事リンクに付けていた計測用パラメータについては、RSS記事リンクのutm_sourceで検索流入を誤計上しかけた話のとおり、中継とリダイレクトでURLから外す方法をとっています。テストでは、検索0件、複数条件の組み合わせ、未知の値、計測用パラメータだけのURLをそれぞれ区別して確かめ、E2Eではnoindexを返すページのcanonicalが公開トップと一致することも検証しています。

なお、この修正は前のH2で書いたページ送りの変更と同じ日に、後から入れたものです。ページ送りの変更の時点では「絞り込みを伴う一覧は2ページ目の扱いの対象外」としていただけで、noindexは付けていませんでした。後の変更で、まず絞り込み・個人別モードの判定を行ってnoindexを返し、それ以外の公開一覧についてだけページ番号を見る順番に並べ替えています。

noindexは付けるのも外すのも慎重さが要ると感じたのが、同じ10月3日に取ったURL検査の結果です。あるトピックページは、その時点ではnoindexが付いておらず、sitemapにも載っていたのに、Googleの保存状態は「noindexタグによって除外」のままでした。最終クロールは9月29日で、URL検査で見えるのはそのときにGoogleが保存した記録です。現在の設定とは別に、過去に受け取った状態が残り続けることがあります。いま返しているHTMLと、Googleが覚えている状態は分けて見る必要があります。

IT女子 アラ美
空っぽの検索結果がトップと同じ顔してたら、検索エンジンも困るわよね。

ITアライグマ
はい。canonicalに任せきりにせず、初期HTMLでnoindexを明示しました。

更新日時が「更新」を表していなかった ─ 中身が変わった時刻を別に持つ

3つ目は日時のずれです。sitemapの lastmod と、記事ページの構造化データ(JSON-LD)にある dateModified は、どちらも「このページの中身が最後に変わったのはいつか」を検索エンジンに伝えるための値です。ところが本番のある記事では、次のようになっていました。

項目 実際に入っていた値 その値の意味
sitemapのlastmod 2026-09-25T18:33:50Z 元記事の公開日時
JSON-LDのdateModified 2026-09-26T00:39:02Z DevPickが記事を取り込んだ日時

同じページの「最終更新」が2つの場所で別々の値になっていて、しかもどちらも更新を表していません。DevPickは記事を取り込んだあとに、AIで要約や日本語タイトルを作り、必要なら分析し直します。つまり取り込み後にも中身は変わるのに、記事のデータには「中身が変わった時刻」を持つ場所がありませんでした。sitemapは公開日時(なければ取り込み日時)、JSON-LDは取り込み日時で代用していたのです。トピックページのlastmodも、掲載記事の中で最も新しい公開日時でした。

記事以外のページにも同じ種類のずれがありました。サービス紹介ページのlastmodは9月1日の固定値のままで、実際には10月2日などに内容を更新していました。更新履歴ページのlastmodは、更新履歴そのものではなく最新ニュースの公開日時を使っていたため、ニュースが増えるだけで更新されたことになっていました。

直し方は、記事のデータに「重要なコンテンツが作成・更新された日時」を新しく持たせることです。ここで一番考えたのは、何が起きたらこの日時を進めるか、です。

  • 進める:記事の取り込み、要約の作成・再分析、タイトルや説明文の修正、リンク先のOGPからの概要の補完、記事に付くトピックの追加・削除
  • 進めない:閲覧、いいね、通知の状態、検索エンジンへの更新通知の送信、OGP取得の再試行のような、読者に見える中身が変わらない変更

sitemapとJSON-LDの両方がこの日時を参照するようにし、トピックページのlastmodも掲載記事のこの日時の最大値に変えました。値がまだ入っていない記事は取り込み日時で代用します。サービス紹介ページと規約類は実際に更新した日付、更新履歴ページは最新の更新履歴の日付を使うようにしています。リクエストのたびに現在時刻を返すような、常に「今更新された」と言い張る値にはしていません。

DBへの列の追加は、稼働中のサービスを止めずに入れるために2つ気をつけました。

  • NULLを許す列として追加する:デプロイ中は、新しい列を知らない古いプロセスがまだ記事を登録することがある。NULLを許しておけば、その登録が失敗しない。既存の行は取り込み日時で埋め、NULLのまま残った行は読む側で取り込み日時に代用する
  • DB側の既定値に現在時刻を使わない:本番のMySQLで現在時刻を既定値にすると日本時間で入り、UTCで扱っている他の日時より9時間先になってしまう。既定値はアプリ側でUTCの現在時刻を入れる

実はこの2点は、最初の実装ではできていませんでした。最初はDB側の既定値に現在時刻を使っていて、マージ前の自動レビューで「本番のMySQLでは日本時間で入り、9時間先の日時になる」と指摘されました。それを受けて、NULLを許す列に変え、既定値をアプリ側に移しています。同じレビューでは、コマンドラインからタイトルや要約を修復する処理と、トピックの紐付けを削除する処理が日時を進めていない、という指摘も受けました。OGPからの概要の補完で日時を進める対応も、このとき一緒に入れています。どれもマージ前に同じ変更の中で直したもので、本番で起きた不具合ではありません。「中身が変わる経路」は思っていたより多く、最初の実装では取りこぼしていました。

IT女子 アラ美
いいねされただけで「更新しました」って言われても、検索エンジンからしたら迷惑よね。

ITアライグマ
そうなんです。何を「更新」と呼ぶかを先に決めたのが一番大事でした。

まとめ

今回直した3つのずれは、場所こそ違いますが、どれも「人がブラウザで見る姿」と「検索エンジンが受け取る情報」が食い違っていたものでした。

  • 2ページ目以降のSSRとページ送りのリンク化:初期HTMLとJavaScript実行後の一覧を一致させ、クローラーが辿れる <a href> でページをつないだ。操作感は変えず、ページ番号の上限は共通の読み取り関数でそろえた
  • 絞り込み結果と個人別一覧のnoindex, follow:canonicalのヒントに任せず、初期HTMLのrobotsメタで索引から外した。robots.txtでは遮断しない
  • 中身が変わった時刻を別に持つ:公開日時や取り込み日時で代用せず、何を「更新」と呼ぶかを決めて、その経路でだけ日時を進めた

画面の動作確認は、JavaScriptが動いたあとの姿しか見ません。今回のずれは、未ログインで本番の初期HTMLとsitemapを直接開いたことで初めて見えました。個人開発だとSEOは後回しになりがちですが、ときどきクローラーと同じ目線でページを開いてみると、画面上は正しく見えていても検索エンジンには違う情報が届いている箇所が見つかります。一方で、Googleに保存されている状態は、こちらの修正がすぐ反映されるわけではありません。そのため、URL検査で取れるGoogleの保存状態と、いま返している設定の差を定点で比べる仕組みも別に入れて、修正が伝わったかを追えるようにしました。

今回直した一覧やトピックページは、DevPickで実際に動いています。開発関連ニュースの情報収集に興味がある方は、よかったら触ってみてください。

IT女子 アラ美
正しく見えてるのに届いてない、って一番気づきにくいパターンよね。

ITアライグマ
はい。たまにはクローラーの目線で自分のサイトを見直してみます。

運営サービス・姉妹メディア

この記事をシェアする
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

ITアライグマのアバター ITアライグマ ITエンジニア / PM

都内で働くPM兼Webエンジニア(既婚・子持ち)です。
AIで作業時間を削って実務をラクにしつつ、市場価値を高めて「高年収・自由な働き方」を手に入れるキャリア戦略を発信しています。

目次