How We Localized Our Site into 9 Languages with Astro
We shipped locale routing for 9 languages this sprint. Here’s the honest version of how it happened — including the bug that was quietly hurting us in production before we noticed it.
The Discovery
Back in Sprint 1, we built out the i18n scaffolding you’d expect: locale JSON files for all 9 languages (English, Spanish, Arabic, German, French, Japanese, Portuguese, Russian, Chinese), a getTranslation() helper, getLocalePath() for building locale-aware URLs, and hreflang tags on every page pointing to all 9 locale variants.
What we didn’t build: any actual pages at those locale URLs.
Every page in src/pages/ hardcoded const locale = "en" as const. The translation plumbing existed and worked — but there was nothing behind it. That meant BaseLayout.astro was emitting hreflang tags like https://proman365.com/es/pricing/ on every single page, for URLs that returned a 404 in production.
This isn’t a cosmetic bug. Search engines use hreflang tags to understand which URLs are language variants of each other. Advertising 404s at scale is an active negative SEO signal — it tells crawlers your site’s metadata doesn’t match reality, which erodes trust in all your metadata, not just the broken part.
The Fix: Template Extraction, Not Duplication
The naive fix is copying each page 8 times, once per non-English locale, and hardcoding a different locale constant in each copy. We rejected that immediately — 8x the files means 8x the rot the moment anyone touches a page and forgets the other 7 copies.
The approach we took instead: extract each page’s content into a template component that takes locale as a prop, then render that template from two kinds of routes.
For the 12 Tier-1 pages (home, pricing, contact, partners, help, and the six feature pages), each one moved from src/pages/*.astro into src/page-templates/*.astro, with the hardcoded locale constant replaced by:
interface Props {
locale: Locale;
}
const { locale } = Astro.props;
The original English route becomes a thin wrapper — a few lines that just import the template and render it with locale="en". A new mirror route under src/pages/[locale]/ renders the same template for the 8 non-English locales, using getStaticPaths() to generate one page per locale.
This worked cleanly because every Tier-1 page’s logic — getTranslation(locale), meta tag builders, schema markup — was already parameterized on that one locale constant from the Sprint 1 scaffolding. We weren’t retrofitting i18n into these pages; we were finally wiring up i18n that had been sitting unused.
The result: 96 new static pages (12 pages × 8 locales) from the Tier-1 rollout, followed by a second pass extending the same pattern to the 64 comparison pages (8 competitors × 8 locales).
Gating hreflang Behind a localized Prop
Fixing the routing gap doesn’t automatically fix the hreflang problem — you still need the tags to only claim what’s actually true. We added a localized?: boolean prop to BaseLayout and PageLayout, defaulting to false.
When localized is false, a page emits exactly two hreflang tags: en and x-default. When it’s true, it emits the full set of 10 (all 9 locales plus x-default). Only the Tier-1 templates and the compare pages pass localized={true}.
Defaulting to false was a deliberate safety choice. A page has to opt in to claiming it has 9 language variants. If someone adds a new page tomorrow and forgets to think about locales, it fails safe — English-only metadata, not another round of phantom URLs.
What We Deliberately Didn’t Localize
Not everything got the locale treatment, and that was a decision, not an oversight:
- The blog stays English-only indefinitely. This is standard practice for SaaS marketing blogs, and machine-translating long-form content produces worse copy than just not translating it. If analytics eventually justify dedicated per-locale content, we’ll revisit.
- Legal pages (privacy, terms, cookie policy) stay English. That copy is Termly-generated legal text; translating it isn’t a call we’re equipped to make unilaterally.
- The 404 page stays English — static hosting serves a single 404 regardless of the requested path, so there’s no clean way to localize it without a different hosting model.
- RSS was already English-only and correctly excluded from hreflang; no changes needed there.
The Takeaway Checklist
If you’re doing something similar on a static Astro site:
- Audit before you build. Check whether your hreflang tags point at pages that actually exist. If your i18n scaffolding predates your routing, you may already have this bug.
- Extract templates, don’t duplicate pages. One template per page, thin locale-specific wrapper routes on top. Rot happens at the wrapper layer if it happens at all, and wrappers are cheap to keep in sync.
- Default metadata claims to the narrowest true statement. A
localizedflag that defaults to “no” means new pages can’t accidentally overclaim. - Decide what stays untranslated, on purpose, and write it down. Blog, legal, and error pages are reasonable candidates to exclude — but make it a documented decision, not silence.
- Translation and routing are separate problems. Shipping the routes doesn’t require finishing the translations — English fallback text in a real locale route is still more honest than a working translation key with no route behind it.
We still have manual translation work ahead of us — plenty of strings in the non-English locale files are English placeholders waiting on a human translator. But the URLs are real now, and the metadata stopped lying. That was the more urgent problem.
Alex Rivera
Project manager and workflow consultant. Helps teams find tools that match how they actually work.