目次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.astroavailableLangs 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.idja/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 は出さない、で評価上は問題ないという理解です。