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 を書かなければ、親の値がそのまま出力される。title が template で合成されるのと同じ仕組みが 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 リダイレクトになる。こちらは別の記事に書きました。