0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Astro 標準 i18n で hreflang をリシプロカル運用する最小実装

0
Posted at

Astro 5 の i18n はルーティングまでは面倒を見てくれますが、<link rel="alternate" hreflang> は自分で出す必要があります。

サポートする全 locale 分の alternate を無条件に出す素朴な実装にすると、片方の言語にしか実体がない記事で 404 を指すリンクが alternate として広告されます。Google のリシプロカル hreflang ルールを破るので、検索エンジンに無視される(最悪はサイト全体の多言語シグナルを薄める)形になります。

この記事では、Content Collections を走査して両言語に実体があるときだけ alternate を出す実装を、最小構成で紹介します。

全体像

3 ステップで終わります。

  1. 存在判定ヘルパ: src/lib/posts.tsgetCollection を使って「どの言語に実体があるか」を返す関数を置く
  2. Seo.astro の条件付き出力: availableLangs prop を受け取り、includes("en") / includes("ja")<link rel="alternate"> を出す
  3. レイアウトから配線: 個別記事レイアウトで 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.idja/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 カスタマイズ

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?