Astro 5 の i18n はルーティングまでは面倒を見てくれますが、<link rel="alternate" hreflang> は自分で出す必要があります。
サポートする全 locale 分の alternate を無条件に出す素朴な実装にすると、片方の言語にしか実体がない記事で 404 を指すリンクが alternate として広告されます。Google のリシプロカル hreflang ルールを破るので、検索エンジンに無視される(最悪はサイト全体の多言語シグナルを薄める)形になります。
この記事では、Content Collections を走査して両言語に実体があるときだけ alternate を出す実装を、最小構成で紹介します。
全体像
3 ステップで終わります。
-
存在判定ヘルパ:
src/lib/posts.tsにgetCollectionを使って「どの言語に実体があるか」を返す関数を置く -
Seo.astro の条件付き出力:
availableLangsprop を受け取り、includes("en")/includes("ja")で<link rel="alternate">を出す -
レイアウトから配線: 個別記事レイアウトで
await blogAvailableLangs(slug)を呼び、Seo.astro に渡す
ステップ 1: 存在判定ヘルパ
import { getCollection } from "astro:content";
import type { Lang } from "../i18n";
const LANGS: Lang[] = ["en", "ja"];
export async function blogAvailableLangs(slugWithoutLang: string): Promise<Lang[]> {
const all = await getCollection("blog", ({ data }) => !data.draft);
return LANGS.filter((lang) =>
all.some((e) => e.id === `${lang}/${slugWithoutLang}`),
);
}
ポイントは 2 つ。
-
draft: trueをフィルタで除外: 下書きしかない言語を「実体あり」と判定すると、本ビルドの URL は 404 です -
slug は言語プレフィクスを剥がしたものを渡す前提: Content Collections の
entry.idはja/2026-05-30-foo形式なので、ヘルパに渡す前にentrySlugで剥がす
paginated ページ用の categoryPageAvailableLangs も同じパターンで、各言語の N ページ目が存在するかを Math.ceil(inCat.length / pageSize) で判定します。
ステップ 2: Seo.astro で条件付き出力
---
interface Props {
availableLangs?: Lang[];
lang?: Lang;
// ...
}
const { availableLangs = ["en", "ja"], lang: langProp } = Astro.props;
const lang: Lang = langProp ?? getLangFromUrl(Astro.url);
const currentPath = Astro.url.pathname;
const otherLangPath = altPathForOtherLang(currentPath, lang);
const enPath = lang === "en" ? currentPath : otherLangPath;
const jaPath = lang === "ja" ? currentPath : otherLangPath;
const enHref = new URL(enPath, Astro.site).toString();
const jaHref = new URL(jaPath, Astro.site).toString();
const hasEnVersion = availableLangs.includes("en");
const hasJaVersion = availableLangs.includes("ja");
// x-default points to EN when available; otherwise the only available lang.
const xDefaultHref = hasEnVersion ? enHref : jaHref;
---
{hasEnVersion && <link rel="alternate" hreflang="en" href={enHref} />}
{hasJaVersion && <link rel="alternate" hreflang="ja" href={jaHref} />}
<link rel="alternate" hreflang="x-default" href={xDefaultHref} />
availableLangs のデフォルトを ["en", "ja"] にしておけば、top-level ページ(/、/about/、/blog/)では何もしなくていい。個別記事レイアウトでだけ上書きする運用です。
x-default は片言の記事でも出します。Google が「サポート言語を 1 つは明示する」を推奨しているためです。
ステップ 3: レイアウトから配線
---
import { entrySlug, blogAvailableLangs } from "../lib/posts";
const slug = entrySlug(entry); // "ja/2026-05-30-foo" → "2026-05-30-foo"
const availableLangs = await blogAvailableLangs(slug);
---
<Seo
title={data.title}
description={data.description}
lang={lang}
availableLangs={availableLangs}
// ...
/>
ここで entrySlug を忘れると、getCollection 内の比較が常にミスマッチになって empty array が返り、サイレントに hreflang が出なくなります。ビルドは通るので CI では拾えない、隠れた失敗モードです。
落とし穴
-
デフォルト値の罠:
availableLangs = ["en", "ja"]がデフォルトなので、個別記事レイアウトで上書きを忘れると片言記事でも 2 本 alternate が出てしまう。これが「自前実装で守りたかった failure mode そのもの」になる -
entrySlugの位置: 上で書いた通り、ja/プレフィクス込みで渡すと空配列が返って隠れ失敗 -
paginated:
/blog/build/3/のように EN だけ存在するページが出るパターンはcategoryPageAvailableLangs(category, pageNum, pageSize)で対応 -
x-defaultは片言にも出す: 「片言だから x-default 不要」と思いがちだが、Google ドキュメントでは出すことが推奨
ルーティングを Astro 標準のままにしている理由、@astrojs/sitemap の i18n オプションとの併用判断、運用での失敗パターンは Aulvem 本家にまとめました → hreflang を両言語にある記事だけリシプロカルに出す — Aulvem の i18n カスタマイズ