iOS&Androidスマートフォン開発(Swift/Kotlin/React-Native) および Next.js/LaravelをはじめとしたWeb開発ならば、ぜひ創業13年の弊社にお任せください。ワンストップでお届けします。お問合せはこちらから

canonicalとog:urlが初期HTMLで空だった。window.location.hrefで現在URLを組み立てる罠

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

yuku_tasのアイコン

こんにちは、Webサービス開発者のyuku_tasです。

簡単なプロフィール: MoldSpoon Inc.代表。開業から15年、業界に携わって20年。Webサイト/iOS/Androidアプリ開発から、データ分析業務・コンサル業務、ベンチャーから保険会社まで様々な案件を個人会社の形で請け負ってまいりました。詳しくはこちら

はじめに

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は空
  • 動的URLのページ(/tools/xxx/[id]) → canonicalもog:urlも空

という状態でした。og:urlに至っては全ページ空です。

なぜwindowを見てはいけないのか

Next.jsのページは、リクエスト時(またはビルド時)にサーバー側でHTMLへレンダリングされます。 Node.js上での実行なので、ブラウザのグローバル変数であるwindowはありません。

typeof window !== 'undefined'というガードは、まさにこの環境差を吸収するための定石です。クラッシュは防げます。 ただし今回のように「タグの中身」に使うと、サーバー側では常にフォールバック値(空文字)が採用されます。

クローラーが最初に受け取るのはこのHTMLです。

Googleは後からJavaScriptを実行してレンダリングもしますが、その工程は初回のクロールとは別で、必ず期待通りに扱われるとは限りません。meta情報のような「最初に読ませたいもの」を、実行後のDOMに委ねるのは筋が悪いという話です。

対処:asPathから組み立てる

useRouterasPathサーバー側でも値を持ちます。 ここを起点にすれば、サーバーとクライアントで同じURLを組み立てられます。

hooks/useCurrentUrl.ts
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つあります。

  1. [charset]を含んだURLをそのまま出す
  2. 何も出さない

うちは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が上書きされていないか」も一緒に見てください。全ページ自己参照にしてしまうと、意図的に正規化していたページの設定を壊します。

ついでに見つかった穴:一括リダイレクトが404へ送っていた

同じタイミングで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 },

個別ルールは一括ルールより前に置きます。 上から順に評価されるため、後ろに書くと一括ルールに食われます。

移設そのものの手順は別記事にまとめています。

よくある質問(FAQ)

Q. App Routerでも同じ問題は起きますか?

windowを見て組み立てれば同じことになります。ただしApp RouterにはmetadatagenerateMetadata)というサーバー側でメタ情報を組み立てる仕組みがあるので、素直に使っていればこの罠には落ちません。

metadataBaseと相対パスの組み合わせで絶対URLを作れるので、そちらを使うのが定石です。

Q. 環境ごとにドメインが違う場合、オリジンはどう持てばいいですか?

環境変数に持たせるのが素直です。VercelならNEXT_PUBLIC_VERCEL_URLが使えますが、プレビューデプロイでは毎回違うURLになる点に注意してください。

canonicalは「正規URL」を宣言するタグなので、プレビュー環境がプレビュー自身のURLを主張すると、それはそれで正しい挙動です。むしろプレビューがインデックスされないようにしておくほうが大事です。

Q. 空のcanonicalは実害がありますか?

「絶対に順位が落ちる」と断言できる話ではありません。ただ、うちでは動的ページ8本がインデックス未登録のまま止まっていて、それを説明できる要因のひとつがこれでした。

そもそもhref=""は意図した状態ではないので、直さない理由がありません。

Q. og:urlが空だとSNSシェアはどうなりますか?

多くのSNSはog:urlが無ければ実際のURLを使うので、致命傷にはなりません。ただcontent=""という空の値が入っている場合、無い場合と同じ扱いになる保証はありません。

出さないか、正しい値を出すか。どちらかにしてください。

まとめ

  • typeof window !== 'undefined' ? window.location.href : ''は、サーバー側で必ず空
  • クローラーが読むのは初期HTML。開発者ツールの表示は当てにならない
  • 確認はcurlgrepで。本番ビルドの生成物を見る
  • useRouterの**asPath**から組み立てれば、サーバーとクライアントで一致する
  • 未解決の動的セグメントが残る場合は、誤ったURLを主張するよりタグを出さない
  • 一括リダイレクトは移設先が実在するかを確認する。例外は個別ルールを前に置く

「タグが出ているか」ではなく「中身が入っているか」を見る。この視点があるかどうかだけの話でした。


この記事が何かのお役に立てれば幸いです。
最後までお読みいただきありがとうございました!

Share Me!