Contents14
- What breaks when you emit alternates for every locale
- The two rules from Google
- Three helpers in src/lib/posts.ts
- Conditional emission in Seo.astro
- Wiring layouts to the helpers
- The flow
- Routing stays on the official integration
- Pitfalls
- Wrap-up
- FAQ
- Why doesn’t Astro emit hreflang on its own?
- Aren’t the hreflang entries from @astrojs/sitemap enough?
- What should x-default point to?
- Does omitting hreflang for one-sided posts hurt SEO?
Astro 5’s i18n gives you routing, not hreflang.
A naive “emit one <link rel="alternate"> per supported locale” implementation breaks the moment a post only exists in one language: the alternate points at a 404, Google sees the reciprocal pointer is missing, and the hreflang is quietly ignored. Worst case, it weakens the site’s multilingual signal as a whole.
Aulvem walks Content Collections to detect which language versions actually exist for a given slug, then emits <link rel="alternate" hreflang> conditionally from Seo.astro. This is the third follow-up to How this blog is built, picking up the FAQ note that hreflang and sitemap lastmod were both custom-built on top of Astro’s defaults.
What breaks when you emit alternates for every locale
The naive pattern: a 2-language site emits two <link rel="alternate" hreflang> lines on every page, one for each locale. It looks correct at first.
The break happens when a specific post only exists in one language:
- A JA-only post emits a hreflang pointing at the EN URL → that URL returns 404
- Google notices the EN URL doesn’t reciprocate (no JA pointer because it doesn’t render)
- The hreflang is dropped, and the site’s overall multilingual signal weakens
Google’s hreflang documentation makes both rules explicit: “reciprocal pointers required” and “alternates must point at valid URLs”. A naive implementation fails both and effectively becomes a no-op — or worse, an active liability.
The two rules from Google
Two requirements have to hold for hreflang to count:
- Reciprocal pointers: if
A → Bis emitted,B → Amust exist - Valid URLs: 404 or noindex targets disqualify the alternate
The cleanest way to satisfy both is “only emit an alternate when both languages actually have a published entry”. That requires walking the content at build time to know which entries are bilingual.
Three helpers in src/lib/posts.ts
Aulvem keeps three helpers for the three page types (single post, product page, paginated category):
import { getCollection } from "astro:content";
import type { Lang } from "../i18n";
const LANGS: Lang[] = ["en", "ja"];
// Per-post: which languages have the entry
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}`),
);
}
// Per-product: same, against the services collection
export async function serviceAvailableLangs(slugWithoutLang: string): Promise<Lang[]> {
const all = await getCollection("services");
return LANGS.filter((lang) =>
all.some((e) => e.id === `${lang}/${slugWithoutLang}`),
);
}
// Per-paginated-page: does page N exist in each language?
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;
}
The draft: true filter inside getCollection matters — treating a draft-only language as “available” would emit a hreflang pointing at a URL that doesn’t render on the published build.
Unlike the sitemap lastmod implementation inside astro.config.mjs, these helpers run from layouts during page rendering. The Content Collections API is already initialised by then, so getCollection is the right tool here.
Conditional emission in Seo.astro
Seo.astro accepts an availableLangs prop (defaulting to both) and uses includes("en") / includes("ja") to decide which <link rel="alternate"> to emit:
---
interface Props {
// ... other 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} />
The default value ["en", "ja"] matches the top-level pages (/, /about/, etc.) which are always bilingual. The contract is: per-entry layouts must override availableLangs. Forgetting the override on a per-entry layout means broadcasting a 404 URL through hreflang, which is the exact failure mode the helpers are designed to prevent.
x-default always falls back to EN when both languages exist, since EN is the site’s default locale. Single-language posts still get an x-default pointing at the only available language — Google’s guideline says to declare at least one supported language explicitly.
Wiring layouts to the helpers
BlogPostLayout.astro is the typical caller:
---
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 strips the language prefix (en/ or ja/) before handing the slug to the helper. Forgetting the strip turns the lookup into a full mismatch and silently returns an empty array — quiet failure that doesn’t fail the build.
Paginated routes (/blog/build/[...page].astro) call categoryPageAvailableLangs(category, page.currentPage, 12) instead.
The flow
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
The helper walks Content Collections once and returns a Lang[] to the layout. The layout passes that array to Seo.astro, which emits zero, one, or two <link rel="alternate"> lines plus an x-default.
Routing stays on the official integration
Astro 5’s i18n routing — automatic /ja/... prefixing, locale detection on request — is left untouched. It’s mature enough that there’s no incentive to rewrite it.
Aulvem’s stance is to leave anything the official integration handles well alone and patch only the gaps. Hreflang is purely an SEO-layer concern, so the override lives entirely inside Seo.astro and the helpers. When Astro eventually ships hreflang generation out of the box, peeling the override off is a one-file change.
Pitfalls
A short list of things that almost broke:
availableLangsdefaults to["en", "ja"]: appropriate for top-level pages, but per-entry layouts must override. Forgetting the override is the exact failure mode this whole setup preventsentrySlugplacement: Content Collections IDs areja/2026-05-30-foo. Hand the prefixed ID to the helper and the lookup quietly returns[], which then emits no hreflang at all — silent regressiondraft: truefiltering: handled inside the helper’sgetCollectionfilter. Without it, a draft-only language can be marked “available” and the published build has nothing at that URL- Paginated edge case:
/blog/build/3/can exist in EN but not JA if the categories diverge in length.categoryPageAvailableLangshandles this — without it, page 3 of a category would emit JA alternates pointing at 404 x-defaultfor single-language posts: still emitted, pointing at the one available language. Google recommends declaring at least one supported locale explicitly
Wrap-up
Hreflang only counts when reciprocal pointers and existence checks both hold. Walking Content Collections at build time gives both, and the override fits in roughly 30 lines split between three helpers and a Seo.astro change. Routing stays on the official integration.
The same “frontmatter as the single source of truth” idea drives the Zod schema enforcement and the sitemap lastmod override — three follow-ups to the stack overview.
FAQ
Why doesn’t Astro emit hreflang on its own?
Astro 5’s i18n is scoped to routing — generating URLs and detecting the request locale. SEO meta-tag generation isn’t part of that responsibility. @astrojs/sitemap will emit hreflang inside the sitemap.xml, but the <link rel="alternate"> in the <head> is your job to write. Aulvem keeps that responsibility inside Seo.astro.
Aren’t the hreflang entries from @astrojs/sitemap enough?
The xhtml:link entries inside sitemap.xml are emitted, but the <link rel="alternate" hreflang> in HTML <head> is a separate channel. Google reads both, and missing the <head> channel weakens multilingual recognition at the snippet level. So both belong.
What should x-default point to?
Google describes it as the fallback for users whose target language can’t be determined. Aulvem has EN as the default locale, so x-default points at the EN version when one exists, and falls back to the only available language otherwise. For posts that have both languages, defaulting to EN is fine.
Does omitting hreflang for one-sided posts hurt SEO?
Not emitting hreflang isn’t itself a penalty. What hurts is emitting an alternate that points at a non-existent URL — Google flags the reciprocal mismatch and weakens the whole site’s multilingual signal. Single-language posts are safer with a clean canonical and no alternate than with a broken one.