Internationalization for Static Sites
Publishing a static site in more than one language multiplies everything that was simple about a single-language build. Every page now has siblings in other languages that must link to each other, every URL needs a language prefix that search engines understand, every piece of navigation chrome needs translated strings, and the build time grows roughly linearly with the number of locales. Get the structure right on day one and adding a fifth language is a content task; get it wrong and every new locale is a refactor.
This guide is for documentation teams and indie developers taking an existing Astro, Hugo or Eleventy site from one language to several. It covers the URL structure decision, hreflang and language alternates, how routing works in each generator, keeping translations in sync through Git, when and how to detect language at the edge, and right-to-left layouts. It sits within Choosing the Right Static Site Generator for Production, and connects to Picking an SSG for a Multi-Language Documentation Site and Multilingual Search on Static Sites.
The numbers throughout come from one project: a 640-page product documentation site moved from English-only to English, German, French and Japanese (2,560 pages in total) over two quarters, first on Hugo and later with a parallel Astro prototype.
Choosing a URL Structure
There are three ways to put a language in a URL: a path prefix (example.com/de/), a subdomain (de.example.com) or a separate domain (example.de). For static sites the path prefix wins in almost every case, and the reasons are operational rather than stylistic.
A path prefix keeps the whole site in one build, one deploy and one CDN zone. Cache rules, security headers, redirects and the preview-deploy pipeline all apply unchanged. Search engines treat the site as one property, so authority earned by English pages benefits the German ones. Subdomains need their own DNS records, certificate coverage and often their own host project; separate domains add registration, renewal and a second analytics property.
The other decision is whether the default language gets a prefix. Leaving English at the root (/guide/) and prefixing others (/de/guide/) keeps existing URLs stable when you internationalize a live site — the single most important property when you already rank. Prefixing every language (/en/guide/) is more symmetrical and makes the root URL free for a language chooser or redirect, but it means redirecting every existing URL.
| Structure | Deploys | Certificates | Existing URLs | Crawl signals |
|---|---|---|---|---|
/de/ path, default at root | 1 | 1 | unchanged | shared |
/en/ + /de/ paths | 1 | 1 | all redirected | shared |
de.example.com | 1–4 | wildcard or per host | unchanged | partly separate |
example.de | 4 | 4 | unchanged | separate |
On the documentation project, keeping English at the root meant zero redirects at launch and no measurable change in English organic traffic in Search Console over the following eight weeks, while the new locales started receiving impressions within eleven days.
Language Alternates and hreflang
Search engines need to know that /guide/install/ and /de/guide/install/ are the same page in two languages. The mechanism is hreflang: a set of <link rel="alternate"> elements in each page's head listing every language version, including the page itself, plus an x-default for readers whose language you do not serve.
<link rel="alternate" hreflang="en" href="https://docs.example.com/guide/install/">
<link rel="alternate" hreflang="de" href="https://docs.example.com/de/guide/install/">
<link rel="alternate" hreflang="fr" href="https://docs.example.com/fr/guide/install/">
<link rel="alternate" hreflang="ja" href="https://docs.example.com/ja/guide/install/">
<link rel="alternate" hreflang="x-default" href="https://docs.example.com/guide/install/">
The hard part is not the markup but knowing which files are translations of each other. File-path matching works only while every locale keeps identical slugs; the moment a translator localizes a slug (/de/anleitung/installation/), the link breaks silently. The robust approach is a stable translation key in frontmatter — translationKey: install in Hugo, or a shared id field in an Astro content collection — and generating alternates by grouping on that key at build time. The details, including the reciprocity rule that makes one-sided annotations worthless, are in hreflang and Language Alternates on Static Sites.
Generating the annotations is half the job; validating them is the other. A build-time check that groups every page by key and asserts that each group lists the same set of URLs in every member catches the errors that otherwise surface months later as "wrong language in search results" reports. On the documentation site that check found 37 broken groups in the first run, almost all caused by a translated page being deleted without its siblings being updated.
Routing in Astro, Hugo and Eleventy
Each generator models languages differently, and the model decides how much code you write.
Hugo treats languages as a first-class dimension. You declare them in configuration, and content either lives in per-language directories (content/en/, content/de/) or uses filename suffixes (install.de.md). Menus, taxonomies, string tables (i18n/de.toml) and .Translations are built in, and each language renders as its own site pass. See Multilingual Sites with Hugo.
defaultContentLanguage = "en"
defaultContentLanguageInSubdir = false
[languages.en]
weight = 1
languageName = "English"
contentDir = "content/en"
[languages.de]
weight = 2
languageName = "Deutsch"
contentDir = "content/de"
Astro added an i18n configuration block in version 4.0 that sets locales, the default locale and whether it is prefixed, and provides helpers such as getRelativeLocaleUrl(). Content still has to be organised by you — usually as a locale folder inside each collection — and dynamic routes read the locale from Astro.currentLocale. i18n Routing in Astro walks through the setup and its fallbacks.
// astro.config.mjs
export default defineConfig({
i18n: {
locales: ['en', 'de', 'fr', 'ja'],
defaultLocale: 'en',
routing: { prefixDefaultLocale: false },
fallback: { fr: 'en' }
}
});
Eleventy has an official i18n plugin that provides locale_url and locale_links filters, but the structure — a directory per language with a directory data file setting lang — is convention you build yourself. It is flexible and entirely explicit, which suits teams that want to see every moving part.
Build time scales with locales in all three. On the documentation project, Hugo went from 9.8 s for 640 English pages to 34.1 s for 2,560 pages across four languages (measured with hugo --templateMetrics and hyperfine, five runs). The Astro prototype went from 71 s to 262 s. Neither was a problem; both are worth knowing before you promise a ten-locale roadmap.
Translation Workflows Through Git
The engineering problems in i18n are mostly solved once; the ongoing work is keeping translations in step with a source language that keeps changing. Two practices do most of the work.
First, track source revision per translation. When a translator finishes the German install page, record the Git commit of the English file they translated (sourceCommit: 4f2a91c in frontmatter, or in the translation management system). A CI job can then run git diff 4f2a91c..HEAD -- content/en/install.md and flag the German page as stale whenever the English source has changed since. On the documentation project this job reported 118 stale pages at the end of the first quarter — a number nobody had known.
Second, connect a translation management system (Crowdin, Lokalise, Phrase or Weblate) to the repository so that source changes push strings out and completed translations come back as pull requests. Writers never copy text between systems, and translation changes get the same review, preview deploy and link checks as any other change. Translation Workflows with Git and Crowdin covers the configuration and the branch strategy.
UI strings — navigation labels, "Next page", "Edit this page", date formats — belong in string tables rather than templates. Hugo reads i18n/*.toml, Starlight reads src/content/i18n/*.json, and an Eleventy site can use a global data file per locale. Put them through the same translation pipeline as content.
Detecting Language at the Edge
The tempting feature is automatic redirection: read Accept-Language, send German browsers to /de/. Done on every URL, it causes real harm — shared links change language depending on who clicks them, crawlers (which usually send no language header or en) never reach other locales, and readers who prefer English documentation on a German laptop cannot stay put.
The pattern that works redirects only the root URL, only when there is no explicit choice stored in a cookie, and responds with Vary: Accept-Language or a no-cache redirect so CDNs do not serve a German redirect to a French reader. Every page also keeps a visible language switcher that sets the cookie. A small edge function is enough, and Language Detection and Redirects at the Edge gives a complete Cloudflare Worker for it. It follows the same pattern as Serverless Functions for Static Sites: everything stays static except a few lines on one route.
On the documentation site, redirecting the root only increased the share of German-language visitors who reached a German page on their first view from 31% to 74%, with no change in bounce rate on English pages.
Right-to-Left and Script-Specific Layout
Adding Arabic, Hebrew or Persian changes the layout, not just the text. Set dir="rtl" and lang on the <html> element per locale, and write CSS with logical properties — margin-inline-start instead of margin-left, padding-inline, inset-inline-end, text-align: start — so one stylesheet serves both directions. Icons with a direction (arrows, "next" chevrons) need mirroring; code blocks and numbers stay left-to-right inside an RTL page and need dir="ltr" on the <pre> element.
Japanese and Chinese bring different concerns: larger font files, line-breaking rules (word-break: auto-phrase in recent Chromium), and no spaces for search tokenizers to split on. Subset CJK fonts aggressively or rely on system fonts; a full Noto Sans JP is over 4 MB, and a system stack was the only way the Japanese pages met the 2.5 s LCP budget on a throttled Moto G Power profile. The CSS side is detailed in Right-to-Left Layouts on Static Sites, and font budgets in Font Loading Strategies for Static Sites.
Search, Feeds and Sitemaps per Locale
Everything that indexes the site needs to know about languages. A search index should be split per locale so a German reader does not download English entries — Pagefind does this automatically from the lang attribute, as described in Multilingual Search on Static Sites. Sitemaps can carry hreflang too, via xhtml:link elements, which is useful on very large sites where head annotations add weight to every page; see Generating Sitemaps for Large Static Sites. RSS feeds should be one per language, each with its own <language> element.
Testing a Multilingual Build
A multilingual site has more ways to break than a single-language one, and most of them are invisible in a browser because each page looks fine on its own. Three automated checks in CI cover the important failures.
The first is the alternate-group validator described above: group every built page by its declared alternates and fail when any group is not reciprocal, when an alternate points at a URL that does not exist in the build output, or when a URL appears in two groups. It runs over the built HTML in about four seconds for 2,560 pages with a short Node script using node-html-parser.
The second is a lang and dir assertion: every page under /de/ must carry lang="de", every page under /ar/ must carry dir="rtl". A template bug that drops the attribute on one layout — the 404 page and the search results page were the usual culprits — is caught before it reaches readers or screen readers. The same check belongs in the accessibility suite covered in Testing Static Sites in CI.
The third is a string-completeness report for UI chrome: diff the keys of each locale's string table against the default locale and fail on missing keys, warn on keys identical to the English value. Hugo's --printI18nWarnings flag does this natively; for Astro and Eleventy a ten-line script over the JSON files is enough.
Measured Impact
The four-language launch on the documentation site, measured over the first full quarter after release:
| Measure | English only | Four locales |
|---|---|---|
| Pages built | 640 | 2,560 |
Hugo build, median of five hyperfine runs | 9.8 s | 34.1 s |
| Organic sessions per week (Search Console clicks) | 18,400 | 27,900 |
| Share of sessions from DE, FR and JP | 9% | 38% |
| Broken hreflang groups caught by CI in the quarter | — | 37 |
| Stale translations flagged by the source-commit job | — | 118 |
| Support tickets tagged "wrong language" | — | 4 |
The English traffic line stayed flat, which is the signal that keeping the default language at the root did its job. The growth came entirely from the new locales, and the two CI jobs meant the error rates that usually accompany a multilingual launch were caught in pull requests rather than in search results.
Common Pitfalls
- Missing self-reference in hreflang. Every page must list itself; a group missing its own entry is ignored.
- Linking to a redirect. Alternates must point at the final canonical URL with its trailing slash, not at a URL that 301s.
- Machine-translated stubs. Publishing untranslated pages under a locale prefix creates duplicate content and a poor reader experience; leave them out or show a clear fallback.
- Translating slugs without a key. Path-based alternate matching breaks the first time a slug is localized.
- Redirecting every URL by browser language. Breaks shared links and hides locales from crawlers.
- Forgetting the
langattribute. Screen readers use it to pick a voice; axe-core flags it, and search tools rely on it. - One sitemap entry per language without alternates. Search engines see four unrelated pages instead of one page in four languages.
Key Takeaways
- Use a path prefix per language; keep the default language at the root when the site already ranks.
- Generate hreflang from a stable translation key, include a self-reference and
x-default, and validate reciprocity in CI. - Hugo has the most complete built-in model; Astro 4+ covers routing and leaves content organisation to you.
- Record the source commit for each translation so stale pages are detected automatically.
- Redirect by browser language on the root URL only, and always honour an explicit choice.
- Write CSS with logical properties from the start; RTL then costs almost nothing.
FAQ
Should each language live on a subdirectory, a subdomain or its own domain?
For most static sites a subdirectory per language, such as /de/ and /fr/, is the simplest choice. It shares one deploy, one certificate and one CDN configuration, and search engines treat it as one site. Separate country domains make sense only when you need distinct legal entities or strong local branding.
Do I need hreflang if every page has a language switcher?
Yes. The switcher helps readers, but hreflang annotations tell search engines which URLs are translations of each other so the right language version appears in each market's results. Without them, translated pages can compete with each other or show in the wrong country.
Should the site redirect visitors based on their browser language?
Only on the bare root URL, and only once. Redirecting every page by Accept-Language breaks shared links and blocks crawlers from reaching other languages. Redirect the root, remember an explicit choice in a cookie, and always let readers switch manually.
How do I handle pages that are not translated yet?
Either omit the page in that language and leave it out of hreflang, or publish a fallback that shows the default-language content with a notice. Never publish an empty or machine-generated stub that shares the same URL pattern, because it creates thin duplicate pages.
Which generator has the best built-in i18n support?
Hugo has the most complete built-in support, with per-language content directories, translated menus, string tables and automatic translation linking. Astro has i18n routing built in since version 4 and Starlight adds translated UI strings. Eleventy relies on its i18n plugin plus your own data structure.
Related
- Up: Choosing the Right Static Site Generator for Production — how language support weighs in generator choice.
- hreflang and Language Alternates on Static Sites — generating and validating alternates.
- i18n Routing in Astro — locales, fallbacks and helpers.
- Multilingual Sites with Hugo — languages as a first-class dimension.
- Translation Workflows with Git and Crowdin — keeping translations in step.
- Language Detection and Redirects at the Edge — a root-only redirect Worker.
- Right-to-Left Layouts on Static Sites — logical CSS and mirrored UI.
- Technical SEO for Static Sites — sitemaps, canonicals and structured data.
- Picking an SSG for a Multi-Language Documentation Site — the selection view.
- Multilingual Eleventy Sites With the i18n Plugin — build a multilingual Eleventy site with the official i18n plugin: per-language folders, locale-aware links and dates, translated strings and a switcher.
- Subdirectories vs Subdomains for Translated Static Sites — compare subdirectories, subdomains and country domains for a translated static site on build setup, hosting, caching, SEO and cost, with measured numbers.
- Fixing Hreflang Errors Reported in Search Console — diagnose missing return links, wrong language codes and non-canonical targets in hreflang on a static site, fix them in the templates, and test them in CI.