DEV Community

Aulvem
Aulvem

Posted on

Reciprocal hreflang in Astro, only when both languages exist

Astro 5's i18n routing handles URL generation and locale detection, but it doesn't emit <link rel="alternate" hreflang> for you.

The naive fix — emit one alternate per supported locale on every page — quietly breaks when an entry only exists in one language. The alternate points at a 404, Google flags the reciprocal mismatch, and the hreflang is ignored. Worst case, it weakens the site's overall multilingual signal.

This post walks through the minimum override: walk Content Collections, detect which languages have the entry, then emit <link rel="alternate"> conditionally from Seo.astro.

What the override looks like

Three pieces:

  1. Helper functions in src/lib/posts.ts that ask the collection "which languages have this slug?"
  2. Seo.astro takes an availableLangs prop and emits <link rel="alternate"> only for those languages
  3. Layouts call the helper and pass the result to Seo.astro

Step 1: existence helpers

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}`),
  );
}
Enter fullscreen mode Exit fullscreen mode

Two details to get right:

  • Drop draft: true in the filter: treating a draft-only language as "available" emits a hreflang pointing at a URL the published build won't serve
  • Pass the slug without the language prefix: Content Collections IDs are ja/2026-05-30-foo. entrySlug strips the prefix before the lookup

For paginated category pages, the equivalent helper checks whether page N exists per language with Math.ceil(inCat.length / pageSize).

Step 2: conditional emission in 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} />
Enter fullscreen mode Exit fullscreen mode

availableLangs = ["en", "ja"] as the default makes top-level pages (/, /about/, /blog/) work without explicit configuration — those are always bilingual. Per-entry layouts must override.

x-default is emitted even for single-language posts, pointing at the only available language. Google's documentation recommends declaring at least one supported locale explicitly.

Step 3: wiring layouts

---
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}
  // ...
/>
Enter fullscreen mode Exit fullscreen mode

entrySlug strips the language prefix before the helper lookup. Forgetting this turns every comparison into a miss and the helper returns [] — no <link rel="alternate"> ends up in the output, and the build still passes. That's a silent regression worth watching for.

Pitfalls

  • The default value bites if you forget to override: availableLangs = ["en", "ja"] is the default. A per-entry layout that doesn't pass an override emits alternates for both languages even when only one has an entry — the exact failure mode this whole setup was meant to fix
  • entrySlug placement: stripping the prefix is mandatory. Without it, every helper call returns [] silently
  • Paginated routes: /blog/build/3/ can exist in one language and not the other if the post counts diverge. categoryPageAvailableLangs(category, pageNum, pageSize) handles this
  • x-default for single-language posts: emit it anyway. Google recommends declaring at least one supported locale explicitly

The longer write-up — why routing stays on the official integration, how this interacts with @astrojs/sitemap's hreflang output, and the operational failure modes — is on the Aulvem site → Emitting reciprocal hreflang only when both languages exist — Aulvem's i18n customisation

Top comments (0)