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.

The main stages of internationalizing a static site Six stages in order. Choose a URL structure with a subdirectory per locale. Add hreflang alternates generated from a translation key. Configure routing in the generator. Connect a translation workflow through Git. Add an edge redirect on the root URL only. Finish with direction-aware CSS for right-to-left languages. From one language to four without a rewrite URL structure /de/ /fr/ /ja/ subdirectories Alternates hreflang from a shared key Routing Astro i18n or Hugo languages Translation flow Git branches and a TMS Edge detection root URL redirect only Direction logical CSS for RTL
Each stage builds on the one before; the URL structure and the translation key are the two decisions that are expensive to change later.

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.

StructureDeploysCertificatesExisting URLsCrawl signals
/de/ path, default at root11unchangedshared
/en/ + /de/ paths11all redirectedshared
de.example.com1–4wildcard or per hostunchangedpartly separate
example.de44unchangedseparate

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.

Decision tree for locale URL structure The root question asks whether the existing site already ranks and must keep its URLs. If yes, keep the default language at the root and prefix only the new languages. If starting fresh, prefix every language, including the default. If separate legal entities or country-specific brands are required, use country domains at the cost of four deploys and separate crawl signals. Which URL structure for the new languages? Does the current site rank and need stable URLs? Yes, live site Default at root prefix only /de/ /fr/ /ja/; zero redirects No, new site Prefix every locale /en/ /de/ ...; root becomes chooser Separate legal entities Country domains one deploy and property each
The path-prefix options share one deploy and one CDN configuration; country domains are a business decision, not a technical one.

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.

How a translation key produces hreflang links Three language files, English, German and Japanese, each declare the same translation key in frontmatter. The build groups pages by that key, then writes the complete alternate set into the head of every page in the group, so every page links to every other and to itself. A validator then checks that the sets are identical in each member. Generating reciprocal alternates from one key en/install.md translationKey: install Build group by key de + ja pages same key Validator reciprocity check key install plus URL key install plus two URLs write all four alternates write the same four alternates every group identical in every member? fail the build on the first mismatch
Grouping by key rather than by file path survives localized slugs, which is where path matching usually breaks.

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.

Build time growth when adding three locales Paired bars comparing one language with four languages. Hugo grows from 9.8 seconds to 34.1 seconds. The Astro prototype grows from 71 seconds to 262 seconds. Both scale roughly linearly with page count. Build time, one language versus four English only (640 pages) Four locales (2,560 pages) Hugo 0.134 9.8 s 34.1 s Astro 4.16 prototype 71 s 262 s
Median of five hyperfine runs on the same 8-vCPU CI runner; page count went from 640 to 2,560.

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.

Translation workflow layers from source edit to deploy An English writer edits source content and merges it. A sync job pushes changed strings to the translation management system. Translators work there. Completed translations return as a pull request that gets a preview deploy and link checks. Merging deploys all locales together. A translation change travels like any other change English source merged content/en/**, commit 4f2a91c stale check marks de/fr/ja pages Sync to translation system new and changed strings only about 1,900 words per week Translators and review glossary and translation memory median 3.5 days per batch Pull request back to Git preview deploy plus link check same gates as English
The translation management system is a participant in Git, not a parallel publishing channel.

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.

Effect of a root-only language redirect Vertical bars. With no redirect, 31 percent of visitors whose browser prefers German reached a German page on their first view. With a root-only redirect that respects a cookie, 74 percent did. With a redirect on every URL, the figure was 79 percent but 12 percent of shared links opened in the wrong language, so the every-URL variant was rejected. German-speaking visitors reaching German content on first view 31% No redirect 74% Root-only redirect with cookie 79% Redirect every URL (rejected)
Measured over four weeks per variant from first-party analytics with the browser language recorded.

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.

Physical and logical CSS properties compared Two panels. Physical properties such as margin-left, padding-right, left and text-align left need a second RTL stylesheet or overrides, around 140 rules on the sample site. Logical properties such as margin-inline-start, padding-inline-end, inset-inline-start and text-align start flip automatically with the dir attribute and need no overrides. Physical versus logical CSS in an RTL locale Physical properties margin-left and padding-right left: 0 on the sidebar text-align left mirrored copy of each rule for RTL 140 override rules to maintain Logical properties margin-inline-start and padding-inline-end inset-inline-start: 0 text-align start dir="rtl" flips the layout 0 override rules
Converting to logical properties removed the separate RTL override file entirely.

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:

MeasureEnglish onlyFour locales
Pages built6402,560
Hugo build, median of five hyperfine runs9.8 s34.1 s
Organic sessions per week (Search Console clicks)18,40027,900
Share of sessions from DE, FR and JP9%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 lang attribute. 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.