目次14
Astro 標準の i18n はルーティングを生成してくれるが、hreflang は自分で出す必要がある。
素朴に「サイトの全 locale 分の alternate を出す」実装にすると、片方の言語にしか実体がない記事でも alternate が出てしまい、結果として 404 を指すリンクが hreflang として広告される。これは Google のリシプロカル hreflang ルール違反で、検索エンジンに壊れた多言語シグナルを送ることになる。
Aulvem では Content Collections を見て両言語に実体がある時だけ <link rel="alternate" hreflang> を出すヘルパを書き、Seo.astro が条件付き出力する構成にした。Aulvem ブログの作り の FAQ で予告した「hreflang は MDX frontmatter から独自実装した」の派生記事 3 本目です。
全 locale に alternate を出すと何が壊れるか
雑な実装でよく見るのが、「サイトがサポートする全 locale 分の <link rel="alternate" hreflang> を無条件で出す」パターンだ。EN / JA の 2 言語サイトなら、どの記事でも常に 2 本の alternate を吐く。
問題はここで、特定の記事が片言にしか存在しない場合に起きる。
- JA だけ書いた記事に EN の alternate が出る → 指す URL は 404 になる
- Google は「指された URL が alternate を返さない」と判断する(リシプロカル要件不成立)
- 結果としてその hreflang は無視される。最悪、サイト全体の多言語シグナルが薄まる
Google の hreflang ガイドライン は明示的に「相互参照が必要」「存在しない URL を指さない」を要件として挙げています。雑な実装は要件不成立で実質ノーカウントなので、出さない方がマシな状況も起きる。
Google が要求する 2 つの条件
hreflang を機能させるための必須条件は次の 2 つだ。
- リシプロカル:
A → Bの hreflang を出すなら、B → Aも同じ alternate を持つ - 存在する URL を指す: 404 / noindex の URL を alternate に書かない
両方を満たすには「両言語に実体があるときだけ alternate を出す」が一番素直で safe な実装になる。サイトのビルド時に Content Collections を走査して、各記事がどの言語に存在するかを判定する仕組みが必要です。
実体存在判定ヘルパ 3 つ
Aulvem では 3 種類のページ系(個別記事 / プロダクト / カテゴリ paginated)に対応するヘルパを src/lib/posts.ts に置いている。
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}`),
);
}
// プロダクトページ → どの言語に存在するか
export async function serviceAvailableLangs(slugWithoutLang: string): Promise<Lang[]> {
const all = await getCollection("services");
return LANGS.filter((lang) =>
all.some((e) => e.id === `${lang}/${slugWithoutLang}`),
);
}
// カテゴリ paginated ページ → N ページ目が言語ごとに存在するか
export async function categoryPageAvailableLangs(
category: string,
pageNum: number,
pageSize: number,
): Promise<Lang[]> {
const results: Lang[] = [];
for (const lang of LANGS) {
const posts = await getPublishedBlog(lang);
const inCat = posts.filter((p) => p.data.category === category);
const lastPage = Math.max(1, Math.ceil(inCat.length / pageSize));
if (pageNum <= lastPage) results.push(lang);
}
return results;
}
draft: true を除外しているのがポイントで、下書きしかない言語を「実体あり」と判定すると、本ビルドの sitemap や本文側と不整合になる。getCollection のフィルタ段階で落とす。
Seo.astro はビルド時にレンダリングされるので、ここでは Content Collections API が使える。astro.config.mjs での sitemap lastmod 実装 では loader より早く評価される制約があって API が使えなかったが、layout から呼ぶ場合はその制約は無いです。
Seo.astro で条件付き出力
Seo.astro は availableLangs prop を受け取り、includes("en") / includes("ja") で <link rel="alternate"> を条件付き出力する。
---
interface Props {
// ... 他の props
availableLangs?: 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} />
デフォルト値を ["en", "ja"] にしているのは、top-level ページ(/、/about/ など)は両言語に必ず存在する前提だからだ。個別記事のレイアウトでは必ず availableLangs を上書きする ルールにして、上書きを忘れるとビルド時に 404 URL が hreflang に乗ってしまう失敗モードを潰しておく。
x-default は両言語にあれば EN にフォールバックする。Google は「サポート言語の 1 つを必ず明示する」を推奨しているので、片言にしかない記事でもその唯一の言語を x-default にしています。
使用側レイアウト
BlogPostLayout.astro から呼び出すときはこう書く。
---
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 で言語プレフィクスを剥がしてから、ヘルパに渡す。プレフィクス込みで渡すと判定が空になるので、ここを間違うとサイレントに hreflang が出なくなる。
カテゴリ paginated 系(/blog/build/[...page].astro)は categoryPageAvailableLangs(category, page.currentPage, 12) を渡す。
全体図
flowchart LR
MDX[blog MDX en+ja]
Helper[blogAvailableLangs<br/>serviceAvailableLangs<br/>categoryPageAvailableLangs]
Layout[BlogPostLayout<br/>ServiceLayout<br/>category route]
Seo[Seo.astro<br/>availableLangs prop]
HTML[hreflang 0~2 + x-default]
MDX -->|getCollection| Helper
Helper --> Layout
Layout --> Seo
Seo --> HTML
ヘルパは Content Collections を 1 回走査し、レイアウトに Lang[] を返す。レイアウトがその配列を Seo.astro に渡し、Seo.astro が条件付きで <link rel="alternate"> を 0〜2 本+ x-default 1 本出力します。
ルーティングは Astro 標準のまま
@astrojs/i18n 系のルーティング(/ja/... プレフィクスの自動付与、locale 判定)は触っていない。Astro 5 標準で十分に成熟していて、書き換える理由がない。
Aulvem のスタンスは「公式統合で足りる部分は触らず、足りない部分だけ独自実装で薄く埋める」。hreflang は SEO 層の話なので Seo.astro 内に閉じる形にして、ルーティング層には手を入れない。これで Astro が将来 hreflang 自動生成を実装したときに、Seo.astro 側を剥がすだけで済む構造になる、と思う。
落とし穴と運用ルール
実装で踏みかけた、または踏んだ話を整理しておく。
availableLangsのデフォルトは["en", "ja"]: top-level ページ前提。個別記事のレイアウトで上書きし忘れると、片言記事でも 2 本 alternate が出てしまうentrySlugのタイミング: Content Collections のentry.idはja/2026-05-30-fooの形なので、ヘルパに渡す前に必ずentrySlugで言語プレフィクスを剥がすdraft: true除外: ヘルパ内のgetCollectionフィルタで落としている。これを忘れると、下書きしかない言語版を「実体あり」と判定する- paginated の片言ケース:
/blog/build/3/のような 3 ページ目以降は、EN と JA で投稿数が違うと片方しか存在しないことがある。categoryPageAvailableLangsで対応 x-defaultを片言にも出す: Google が「サポート言語を 1 つは明示する」を推奨しているため。片言の記事でもx-defaultだけは出して、サポート言語を明示する
まとめ
リシプロカル要件と存在チェックの 2 つを揃えないと、hreflang シグナルは検索エンジンに無視されます。Aulvem では Content Collections の実体を見て出すかどうかを判定し、Seo.astro が条件付きで HTML に流す構成にしました。ルーティングは Astro 標準のまま、SEO 層だけ 30 行ほどの追加で済んでいます。
frontmatter を単一情報源として扱う設計は Zod schema で運用ルールを強制する と sitemap の lastmod を MDX frontmatter から自分で差し込む でも同じ系列の話を書いたので、合わせて読むと位置づけが分かりやすいかもしれない。
よくある質問
Astro 標準が hreflang を自動で出さないのはなぜ?
Astro 5 の i18n はルーティング(URL の生成と locale 判定)に責任を絞っていて、SEO メタタグ生成までは持ちません。@astrojs/sitemap の i18n オプションで sitemap 内の hreflang は出ますが、HTML の <link rel="alternate"> は自分で書く必要があります。Aulvem ではここを Seo.astro に集約しています。
@astrojs/sitemap の i18n オプションで出る hreflang ではダメ?
sitemap.xml 内の xhtml:link は出ますが、HTML の <head> に入る <link rel="alternate" hreflang> は別系統です。Google は両方を併用して読みますが、<head> 側が無いと検索結果のスニペット段階で多言語認識が弱くなる、というのが運用上の判断です。
x-default は何を指すべき?
Google のドキュメントは「ユーザーがどの言語ページに着地すべきか不明なときの fallback」と説明しています。Aulvem は EN をデフォルトロケールに置いているので EN があれば EN、EN が無ければ唯一存在する言語版を指す実装にしました。両言語にある記事は迷わず EN にフォールバックでよいと思います。
1 言語にしか実体がない記事の SEO 評価は損なわれない?
hreflang を出さないこと自体が罰になるわけではありません。むしろ存在しない URL を alternate として出して Google に無視される方が、サイト全体の多言語シグナルを濁します。実体がある言語版だけ canonical を立て、ない言語の alternate は出さない、で評価上は問題ないという理解です。