hreflang and Language Alternates on Static Sites
When a static site exists in several languages, search engines need to be told which URLs are translations of one another. Without that signal, the German and English versions of an installation guide compete for the same query, the wrong one shows in Munich, and the translated page you paid for sits unseen. The signal is hreflang, and on a static site it is entirely a build-time concern: every page in a language group must list every member of the group, itself included, and the lists must agree.
This guide shows how to generate those alternates from a stable translation key — not from file paths — in Hugo, Astro and Eleventy, and how to validate them in CI so broken groups fail a pull request instead of quietly degrading search results. It is part of Internationalization for Static Sites. The examples come from a 640-page documentation site published in English, German, French and Japanese.
Prerequisites
- A site with at least two locales under path prefixes, for example English at the root and German under
/de/. - A canonical site URL configured in the generator (
baseURLin Hugo,sitein Astro, a global data value in Eleventy), because alternates must be absolute URLs. - A consistent trailing-slash convention, since alternates must match canonical URLs exactly — see Canonical URLs and Duplicate Content on Static Sites.
Why Path Matching Breaks
The shortcut most sites start with is to derive alternates from the path: if /guide/install/ exists, assume /de/guide/install/ is its German version. It works on launch day and fails the first time a translator localizes a slug to /de/anleitung/installation/, or when a page exists in three languages but not the fourth. The failure is silent — the German page simply stops listing its siblings.
The fix is a translation key: a stable identifier in frontmatter that every language version shares, independent of the file name or URL. Hugo has this built in as translationKey; in Astro and Eleventy you add a field yourself.
---
title: Installation
translationKey: install
---
The Recipe in Hugo
Hugo does the grouping for you. Pages that share a translationKey — or, without one, the same path relative to their language's content directory — appear in .AllTranslations, which includes the current page. The partial is short:
{{/* layouts/partials/hreflang.html */}}
{{ if .IsTranslated }}
{{ range .AllTranslations }}
<link rel="alternate" hreflang="{{ .Language.LanguageCode }}" href="{{ .Permalink }}">
{{ end }}
{{ with index (where .AllTranslations "Language.Lang" site.Sites.Default.Language.Lang) 0 }}
<link rel="alternate" hreflang="x-default" href="{{ .Permalink }}">
{{ end }}
{{ end }}
Set languageCode per language (de-DE, or just de if you do not target regions) so the attribute value is valid BCP 47. .IsTranslated prevents a lone self-reference on pages that exist in only one language, which would be harmless but noisy.
The Recipe in Astro
Astro's content collections do not know about translations, so the grouping is yours. Put the key in the collection schema, then build a map once per build:
// src/lib/alternates.ts
import { getCollection } from 'astro:content';
const docs = await getCollection('docs');
const byKey = new Map<string, { lang: string; url: string }[]>();
for (const entry of docs) {
const [lang] = entry.id.split('/'); // docs/de/install.md → "de"
const url = new URL(entry.data.permalink, import.meta.env.SITE).href;
const list = byKey.get(entry.data.translationKey) ?? [];
list.push({ lang, url });
byKey.set(entry.data.translationKey, list);
}
export function alternatesFor(key: string) {
return byKey.get(key) ?? [];
}
The layout renders each alternate and an x-default pointing at the English member. Because the map is computed at module scope, it runs once per build rather than once per page; on the Astro prototype of the documentation site, computing it per page had added 11 s to a 262 s build.
The Recipe in Eleventy
In Eleventy, a collection keyed by translationKey does the same job. Add it in the config and read it in the base layout:
// eleventy.config.js
eleventyConfig.addCollection('byTranslationKey', (api) => {
const groups = {};
for (const page of api.getAll()) {
const key = page.data.translationKey;
if (!key) continue;
(groups[key] ??= []).push({ lang: page.data.lang, url: page.url });
}
return groups;
});
{% for alt in collections.byTranslationKey[translationKey] %}
<link rel="alternate" hreflang="{{ alt.lang }}" href="{{ site.url }}{{ alt.url }}">
{% endfor %}
The official i18n plugin's locale_links filter can do similar work, but it matches by path, so keep the key-based collection if translators localize slugs.
Validating Groups in CI
Generating alternates is only reliable if something checks them. The validator parses the built HTML, not the source, so it catches template bugs as well as content mistakes. It asserts four things for every page with alternates: the page lists itself, every listed URL exists in the build output, every listed URL lists back the same set, and no URL belongs to two different groups.
// scripts/check-hreflang.mjs
import { globby } from 'globby';
import { parse } from 'node-html-parser';
import { readFile } from 'node:fs/promises';
const SITE = 'https://docs.example.com';
const pages = new Map();
for (const file of await globby('dist/**/*.html')) {
const root = parse(await readFile(file, 'utf8'));
const url = SITE + '/' + file.replace(/^dist\//, '').replace(/index\.html$/, '');
const alts = root.querySelectorAll('link[rel=alternate][hreflang]')
.filter((l) => l.getAttribute('hreflang') !== 'x-default')
.map((l) => l.getAttribute('href')).sort();
if (alts.length) pages.set(url, alts.join('|'));
}
let errors = 0;
for (const [url, set] of pages) {
const members = set.split('|');
if (!members.includes(url)) { console.error('no self-reference:', url); errors++; }
for (const m of members) {
if (!pages.has(m)) { console.error('missing alternate target:', m, 'from', url); errors++; }
else if (pages.get(m) !== set) { console.error('non-reciprocal group:', url, '<->', m); errors++; }
}
}
process.exit(errors ? 1 : 0);
It ran in 4.1 s over 2,560 pages on a GitHub Actions ubuntu-latest runner. Add it after the build step and before deploy, alongside the link checker from Checking Links in Pull Requests.
Head or Sitemap?
Alternates can live in the HTML head or in the XML sitemap as xhtml:link elements. Both are equivalent to search engines. Head annotations are easier to reason about and debug with a browser. Sitemap annotations keep pages lighter: with four locales each page carries five <link> elements, about 520 bytes before compression, which is negligible; with twenty locales it is 2.5 KB of head markup on every page, repeated across tens of thousands of pages. The sitemap approach is described in Generating Sitemaps for Large Static Sites. Do not use both with different data; if you use both, generate them from the same map.
Language Codes, Regions and Partial Groups
The hreflang value is a BCP 47 language tag, optionally followed by a region: de, de-AT, pt-BR, zh-Hant. Use the language alone unless the content genuinely differs by region — prices, legal text, spelling conventions that readers notice. A documentation site almost never needs regions; an e-commerce catalogue often does. When you do use regions, add a language-only fallback in the same group (de alongside de-DE and de-AT) so readers in Switzerland still match something German.
Partial groups are normal. If the troubleshooting page exists in English and German but not yet in French and Japanese, its group has two members and an x-default, and the French page for the parent section simply does not link to a French troubleshooting page. That is correct behaviour and it is exactly what key-based grouping produces automatically. The mistake to avoid is filling the gap with a fallback page at /fr/troubleshooting/ that contains English text but declares lang="fr": search engines see a French URL with English content, and readers see a language switcher that lies. If you want a fallback, render it with lang="en", a visible notice, and a canonical pointing at the English page, and leave it out of the alternate group.
Finally, keep the language switcher and the alternates in agreement. Both should be driven by the same map; a switcher that offers "Français" on a page whose group has no French member sends readers to a 404, and a checker in End-to-End Tests for Static Sites with Playwright can click every switcher option on a sample of pages to confirm it.
Measured Impact
| Measure | Before (path matching) | After (keys + validator) |
|---|---|---|
| Broken language groups | 37 | 0 |
| Search Console "no return tags" errors | 212 URLs | 0 after recrawl (19 days) |
| German pages shown for German queries (sample of 40 queries) | 26 | 38 |
| Build time added by grouping (Astro) | 11 s (per page) | 0.3 s (module scope) |
| Validator runtime in CI | — | 4.1 s |
Pitfalls & Rollback
- Relative URLs in
href. Alternates must be absolute; a missingsitesetting in Astro silently produces relative paths. - Region codes you do not mean.
hreflang="de-DE"excludes Austrian and Swiss readers from the signal; usedeunless the content is really region-specific. - Draft pages in groups. A draft translation excluded from the build but still counted in the key map yields a 404 alternate; filter drafts before grouping.
- x-default pointing at a redirect. If the root redirects by language, point
x-defaultat the language chooser or the default-language page, not at/. - Rollback: alternates are pure markup. Reverting the partial or layout commit and redeploying removes them, and search engines fall back to treating each page independently.
Conclusion
hreflang on a static site is a grouping problem. Give every translation a shared key, let the build turn each group into a complete, reciprocal set of links with a self-reference and an x-default, and check the built output in CI. On the documentation site that removed 212 Search Console errors and moved German results from 26 to 38 correct answers out of 40 sampled queries.
FAQ
Does every page need to list itself in its hreflang set?
Yes. Each page must include an alternate for its own URL as well as every other language version. Search engines ignore a group where the self-reference is missing or where the pages do not list each other reciprocally.
What is x-default for?
x-default names the URL to show readers whose language or region you do not target, usually the default-language page or a language chooser. It is optional but recommended, and it should appear in every member of the group.
Should hreflang go in the HTML head or in the sitemap?
Either works, but pick one. Head annotations are simplest for small and medium sites. On very large sites with many locales, putting alternates in the sitemap keeps every HTML page lighter, since each page otherwise carries one link element per language.
Can hreflang point at a URL that redirects?
It should not. Alternates must point at the final canonical URL, including the trailing slash convention the site uses. A redirecting alternate is treated as an error and the group can be ignored.
Related
- Parent: Internationalization for Static Sites — URL structure and the rest of the i18n picture.
- Multilingual Sites with Hugo — Hugo's built-in translation model.
- i18n Routing in Astro — locales and fallbacks in Astro.
- Canonical URLs and Duplicate Content on Static Sites — alternates must match canonicals.
- Multilingual Search on Static Sites — one index per language.