Next.js

Next.js の canonical が全ページ同じになる:layout の metadata は子に降りてくる

App Router の metadata で canonical をレイアウトに書くと、その値がサイト全ページに出ます。エラーも警告も出ないので、ビルド後の HTML を開くまで気づけません。

grep -o '<link rel="canonical" href="[^"]*"' out/about/index.html
<link rel="canonical" href="https://eliseapps.com/"

/about/ が、自分はトップページだと申告しています。/blog//support/ も同じ値です。この状態だと、クローラーから見てサイト中のほぼ全ページがトップの複製ということになります。

原因は layout の metadata

犯人はレイアウトに書いたこれです。

// app/layout.tsx export const metadata: Metadata = { title: { template: '%s | Yuki Tech Portfolio', default: 'Yuki Tech Portfolio' }, metadataBase: new URL('https://eliseapps.com'), alternates: { canonical: '/', languages: { 'ja-JP': '/', 'en-US': '/en' }, }, };

トップページの canonical を書いたつもりでした。実際には、App Router の metadata はレイアウトからページへフィールド単位で継承されます。子が alternates を書かなければ、親の値がそのまま出力される。titletemplate で合成されるのと同じ仕組みが alternates にも効いている、というだけの話です。

厄介なのは、これがエラーにも警告にもならないところです。ページは正常にビルドされ、見た目も何も変わりません。out/ の中を覗くまで気づけません。

直し方

レイアウトからは alternates を消します。canonical はページごとに違う値なので、そもそもレイアウトに置けるものではありませんでした。

export const metadata: Metadata = { title: { template: '%s | Yuki Tech Portfolio', default: 'Yuki Tech Portfolio' }, metadataBase: new URL('https://eliseapps.com'), // alternates はここに書かない。子ページ全部に降りてしまう };

そのうえで、各ページが自分の canonical を宣言します。ページ数が増えると同じ形を書き写すことになるので、ヘルパーにまとめました。

// seo.ts export const SITE_URL = 'https://eliseapps.com'; export const ROUTES = { home: '/', about: '/about/', blog: '/blog/', software: '/software/', } as const; export async function pageMetadata(lang: 'ja' | 'en', route: keyof typeof ROUTES) { const path = ROUTES[route]; const jaUrl = `${SITE_URL}${path}`; const enUrl = `${SITE_URL}/en${path}`; return { alternates: { canonical: lang === 'ja' ? jaUrl : enUrl, languages: { 'ja-JP': jaUrl, 'en-US': enUrl, 'x-default': jaUrl }, }, // title, description, openGraph なども同じ場所で組み立てる }; }

ページ側は呼ぶだけです。

// app/about/page.tsx export async function generateMetadata(): Promise<Metadata> { return pageMetadata('ja', 'about'); }

ルート表を一箇所に持つと sitemap.ts からも同じものを使えるので、サイトマップの手動メンテもなくなります。副産物ですが、こちらのほうが効いているかもしれません。

多言語だと、もうひとつ踏む

このサイトは日本語をルートに、英語を /en 以下に置いています。ロケールごとにレイアウトが分かれる構成です。

app/(ja)/layout.tsx → 日本語 app/en/layout.tsx → 英語

英語側のレイアウトはこうなっていました。

import BaseLayout from '@/components/BaseLayout'; export default async function EnLayout({ children }) { return <BaseLayout lang="en">{children}</BaseLayout>; }

共通のレイアウトコンポーネントを呼んでいるので動いているように見えますが、metadata は React コンポーネントの中身とは無関係に、ファイルの export として収集されます。 BaseLayout の中で metadata を定義しても、それは app/en/layout.tsx の export ではありません。

この形だと /en 以下の全ページに <title> すら出ません。日本語側のレイアウトには次の一行があり、差はそれだけです。

import BaseLayout from '@/components/BaseLayout'; export { metadata } from '@/components/BaseLayout'; // これが要る

どうやって気づくか

metadata の不備はビルドを通ります。手元で気づく手段は、出力された HTML を見ることだけです。全ページ分を並べて眺めるのが一番早い。

find out -name index.html | while read f; do printf '%-40s %s\n' "${f#out/}" \ "$(grep -o '<link rel="canonical" href="[^"]*"' "$f" || echo 'MISSING')" done

canonical が全部違う値になっていて、MISSING が一つもなければ通っています。デプロイ前にこれを流すようにしました。

sitemap 側にも似た罠があります。trailingSlash: true なのに sitemap の URL に末尾スラッシュがないと、全エントリが 308 リダイレクトになる。こちらは別の記事に書きました。