2026年09月10日 09:30 / - PV

Search Consoleを眺めていて、共有用の動的ページが**8本ずっと「検出 - インデックス未登録」**のまま止まっていることに気づきました。
ページは表示されます。中身もあります。ブラウザで開発者ツールを開いて<head>を見ても、canonicalはちゃんと入っています。
ところが、クローラーはそれを見ていませんでした。
原因は、現在URLの組み立て方がこうなっていたことです。
const currentUrl = typeof window !== 'undefined' ? window.location.href : ''
サーバー側ではwindowが存在しないので、必ず空文字になります。そしてクローラーが読むのは、そのサーバーが返した初期HTMLです。
この不具合が見つけにくいのは、開発者ツールでは正しく見えてしまうことです。
開発者ツールの「要素」タブが見せているのは、JavaScriptが実行されたあとのDOMです。マウント後にwindow.location.hrefが入るので、そこには正しいURLが入っています。
実際に配信されているHTMLを見るには、ソースを直接取ります。
curl -s https://example.com/some-page | grep -E 'canonical|og:url'
うちで実行した結果はこうでした。
<link rel="canonical" href=""/>
<meta property="og:url" content=""/>
タグは出ているのに、中身が空。「canonicalを入れ忘れた」ならまだ気づけますが、「入っているが空」は目視で見落とします。
さらに悪いことに、canonicalを明示的に渡しているページだけは正しく埋まっていました。だから一部のページは正常で、全体を眺めても異常に見えません。うちの場合、
canonicalUrlを明示指定しているページ → canonicalは正常、og:urlは空/tools/xxx/[id]) → canonicalもog:urlも空という状態でした。og:urlに至っては全ページ空です。
windowを見てはいけないのかNext.jsのページは、リクエスト時(またはビルド時)にサーバー側でHTMLへレンダリングされます。 Node.js上での実行なので、ブラウザのグローバル変数であるwindowはありません。
typeof window !== 'undefined'というガードは、まさにこの環境差を吸収するための定石です。クラッシュは防げます。 ただし今回のように「タグの中身」に使うと、サーバー側では常にフォールバック値(空文字)が採用されます。
Googleは後からJavaScriptを実行してレンダリングもしますが、その工程は初回のクロールとは別で、必ず期待通りに扱われるとは限りません。meta情報のような「最初に読ませたいもの」を、実行後のDOMに委ねるのは筋が悪いという話です。
asPathから組み立てるuseRouterのasPathはサーバー側でも値を持ちます。 ここを起点にすれば、サーバーとクライアントで同じURLを組み立てられます。
import { useRouter } from 'next/router'
const SITE_ORIGIN = 'https://example.com'
/**
* 現在ページの絶対URLを返す。 * window ではなく asPath を使うことで、サーバー側でも同じ値になる。 */
export default function useCurrentUrl(): string {
const { asPath } = useRouter()
// クエリとハッシュは正規化のため落とす
const path = asPath.split(/[?#]/)[0]
// 未解決の動的セグメントが残っている場合は URL を主張しない
if (path.includes('[') || path.includes(']')) return ''
// トップは末尾スラッシュを付けない
if (path === '/') return SITE_ORIGIN
return `${SITE_ORIGIN}${path}`
}
呼び出し側では、空のときはタグごと出さないようにします。
{currentUrl && <meta property="og:url" content={currentUrl} />}
{(canonicalUrl || currentUrl) && (
<link rel="canonical" href={canonicalUrl || currentUrl} />
)}
href=""のような空タグを出し続けるより、出さないほうがマシです。空のcanonicalは「このページの正規URLはこのページ自身(相対解決)」とも読めてしまい、意図しない解釈を招きます。
ここが今回いちばん悩んだ点です。
静的最適化されたページでは、初期HTMLの時点でasPathが/tools/urlencode/[charset]のように未解決のことがあります。ルーターがブラウザ側で初期化されて初めて実際の値が入るためです。
このとき選択肢は2つあります。
[charset]を含んだURLをそのまま出すうちは2にしました。存在しないURLを「これが正規URLです」と主張するくらいなら、黙っていたほうが害が少ないと判断したからです。
ブラウザではなく、本番ビルドの生成物を見ます。
next build
next startうちで確認した結果です。
/ → canonical https://moldspoon.jp(末尾スラッシュなし)/tools/cron → canonical・og:urlとも自ページ/tools/compound/result → canonicalは明示指定した/tools/compoundを維持/tools/business-model-canvas/{id} → canonical・og:urlとも自ページ/tools/urlencode/[charset] → og:urlを出さない「明示指定したcanonicalが上書きされていないか」も一緒に見てください。全ページ自己参照にしてしまうと、意図的に正規化していたページの設定を壊します。
同じタイミングでSearch Consoleの404一覧も潰していて、こんな1本が残っていました。
ブログを/blog配下へ移設したとき、旧URLに一括ルールで301を張りました。
{ source: '/posts/:slug*', destination: '/blog/posts/:slug*', permanent: true }
これで33本が救われたのですが、1本だけ移設先に同名の記事が存在しませんでした。 その記事は連載形式に分割されていて、スラッグが変わっていたのです。
結果どうなったかというと、
旧URL → 308 → 存在しない新URL(404)
リダイレクトは成功しているのに、行き先が404。Search Consoleはこれを「見つかりませんでした(404)」として記録し続けていました。
一括ルールは強力ですが、「移設先が全部実在するか」を確認しないと、404を丁寧にリダイレクトする装置になります。 例外は個別に書きます。
// 分割されて同名の移設先が無い1本だけ、主題に対応する回へ個別に送る
{ source: '/posts/add-db-and-storage-and-custom-domain-to-vapor', destination: '/blog/posts/add-db-to-vapor', permanent: true },
{ source: '/posts/:slug*', destination: '/blog/posts/:slug*', permanent: true },
個別ルールは一括ルールより前に置きます。 上から順に評価されるため、後ろに書くと一括ルールに食われます。
移設そのものの手順は別記事にまとめています。
windowを見て組み立てれば同じことになります。ただしApp Routerにはmetadata(generateMetadata)というサーバー側でメタ情報を組み立てる仕組みがあるので、素直に使っていればこの罠には落ちません。
metadataBaseと相対パスの組み合わせで絶対URLを作れるので、そちらを使うのが定石です。
環境変数に持たせるのが素直です。VercelならNEXT_PUBLIC_VERCEL_URLが使えますが、プレビューデプロイでは毎回違うURLになる点に注意してください。
canonicalは「正規URL」を宣言するタグなので、プレビュー環境がプレビュー自身のURLを主張すると、それはそれで正しい挙動です。むしろプレビューがインデックスされないようにしておくほうが大事です。
「絶対に順位が落ちる」と断言できる話ではありません。ただ、うちでは動的ページ8本がインデックス未登録のまま止まっていて、それを説明できる要因のひとつがこれでした。
そもそもhref=""は意図した状態ではないので、直さない理由がありません。
多くのSNSはog:urlが無ければ実際のURLを使うので、致命傷にはなりません。ただcontent=""という空の値が入っている場合、無い場合と同じ扱いになる保証はありません。
出さないか、正しい値を出すか。どちらかにしてください。
typeof window !== 'undefined' ? window.location.href : ''は、サーバー側で必ず空curl+grepで。本番ビルドの生成物を見るuseRouterの**asPath**から組み立てれば、サーバーとクライアントで一致する「タグが出ているか」ではなく「中身が入っているか」を見る。この視点があるかどうかだけの話でした。
この記事が何かのお役に立てれば幸いです。
最後までお読みいただきありがとうございました!
Share Me!