Last spring a client handed me a brief that would have made me groan in 2022. Five languages. Eleven markets. One dev. "We've already got WPML set up," he said, with the kind of confidence that tells you he'd never actually looked at his Lighthouse scores.
I nuked the WordPress install on day two. Rebuilt the whole thing in Astro. Eight weeks later, three of those locales were on page one of Google. The fourth followed within the month. That experience basically ended my relationship with WordPress for multilingual work.
So. Here's exactly what I do now.
Why WordPress Multilingual Keeps Failing on Performance
Look, I don't enjoy dunking on WordPress. Seahawk has built hundreds of WP sites and we'll probably build hundreds more. But the performance gap on multilingual projects is genuinely hard to defend.
WPML and Polylang both work by generating translated posts at the database layer and routing through PHP. Every request hits the server. Even with a full-page cache (WP Rocket, W3TC, whatever you prefer), you're still fighting PHP initialisation overhead, cache misses on low-traffic locale pages, and hreflang management that becomes a nightmare the moment you have more than two languages.
I clocked a client's five-language WP site at a Time to First Byte of 1.3 seconds on the Italian locale. Same content, rebuilt in Astro as fully static HTML: 94ms. That's not a marginal improvement. That's a different category of product.
Core Web Vitals are a confirmed ranking factor, and static files served from a CDN edge node simply win that battle every time.
Astro's Built-in i18n Routing (And Why It Changed Everything)
Astro shipped proper i18n routing in v3 and refined it significantly in v4. By the time we're operating in 2026, the API is stable and genuinely pleasant to work with.
Here's the basic setup I use. Your astro.config.mjs gets an i18n block:
`` i18n: { defaultLocale: 'en', locales: ['en', 'de', 'fr', 'it', 'es'], routing: { prefixDefaultLocale: false } } ``
That single config tells Astro to serve English at /, German at /de/, French at /fr/, and so on. No plugin. No extra package. It's baked in.
The prefixDefaultLocale: false is deliberate. Most of my clients' primary audience is English-speaking, so I don't want /en/ polluting the canonical structure. Google can figure out the relationship via hreflang.
The File Structure That Actually Scales
This is where most tutorials go wrong. They show you a flat pages/[locale]/index.astro and call it done. Fine for a brochure. Falls apart the moment you have 40 routes.
What I do instead:
`` src/ pages/ index.astro (English) about.astro (English) [locale]/ index.astro about.astro i18n/ en.json de.json fr.json it.json es.json ``
The i18n/ directory holds your translation strings as flat JSON files. en.json is the source of truth. I write every other locale against it. One key missing in de.json ? The German page falls back to English for that string rather than throwing an error, because I add a small t() utility function that handles fallback gracefully.
The Translation Layer: Keep It Boring
I've tried Paraglide JS, i18next, and a handful of others. For Astro static builds my honest recommendation is: don't over-engineer this.
A simple utility file that imports locale JSON and returns a getter function is enough for 90% of projects. Paraglide JS from Inlang is worth considering if you need type-safe translations and your team is large enough to care about compile-time errors on missing keys. For solo work or small agencies, it's overhead you don't need.
What I do care about: keeping the translation files flat rather than deeply nested. "hero.cta.button.label" as a key sounds organised until you're searching for it at 11pm. "heroCTALabel" is ugly but findable.
Handling Plurals and Dynamic Strings
German pluralisation will humble you. So will Arabic if you go there (I once helped a client expand to the UAE and spent a full afternoon just on number formatting).
For plurals I use a tiny inline helper:
`` export function plural(count, one, other) { return count === 1 ? one : other; } ``
Crude? Yes. But it covers 95% of cases for European languages. If you're going into Arabic, Russian, or Polish, you need proper CLDR plural rules. The Unicode CLDR project documents these exhaustively. Build your plural helper against those specs or you will get it wrong.
hreflang: The Part Everyone Gets Wrong
Right. This is where multilingual SEO actually lives or dies.
hreflang tells Google which version of a page to serve to which locale. Get it wrong and you either cannibalise your own rankings or you serve the German version to French speakers. I have seen both happen on sites I inherited.
The rules:
- Every page must include hreflang tags for every locale, including itself.
- The tags must be bidirectional. If
/de/aboutreferences/fr/about, then/fr/aboutmust reference/de/aboutback. - Include an
x-defaulttag pointing to your default locale. - Use absolute URLs. Always.
In Astro I generate these inside a BaseLayout.astro component that every page imports. I pass in the current path, loop over the locale array, and spit out the <link rel="alternate"> tags automatically. No manual maintenance.
Missing the x-default tag is the single most common mistake I see. Google's own hreflang documentation is genuinely clear on this. Read it once properly.
Locale-Specific Sitemaps
One sitemap with all locales mixed together works. But separate sitemaps per locale (submitted individually in Google Search Console) give you cleaner indexing data and make it far easier to spot crawl errors on a specific language.
My Astro build generates five files: sitemap-en.xml , sitemap-de.xml , and so on. @astrojs/sitemap supports this with the i18n option out of the box. Use it.
Content Strategy for Multilingual Static Sites
Here's the thing most developers skip entirely: the translation itself.
You can have a technically perfect Astro setup with flawless hreflang and a sitemap that'd make a Googler weep with joy. If your German copy was put through DeepL and never touched by a human, the site will not rank well in Germany.
I'm not being precious about this. I use DeepL constantly. It's an excellent first draft. But for any page you actually want to rank, a native speaker needs to read it. Not necessarily rewrite it. Just read it, fix the weird bits, and confirm the phrasing sounds like something a German person would actually say.
Seahawk had a fintech project last year where the client wanted seven languages and a four-week timeline. We used DeepL for the base, then hired seven freelancers via Gengo for review passes at roughly £0.04 per word. Total translation budget was under £800. That site now ranks top five in four of those markets. The economics work.
Locale-Specific Metadata
Title tags and meta descriptions need localising too. Not just translating. "Best accounting software" doesn't search-map directly to the German equivalent because keyword volumes differ.
I keep locale-specific metadata in the JSON translation files under a dedicated meta namespace. The page template pulls from t('meta.title') and falls back to a default only if the key is missing. This means an SEO can edit one JSON file per locale without ever touching the code.
Deployment: Where to Host a 5-Language Static Site
Vercel and Netlify both handle static Astro builds fine. But for multilingual specifically, you want edge caching per locale and the ability to set locale-specific headers.
I've standardised on Cloudflare Pages. Free tier handles most client budgets. Requests are served from the nearest edge node globally, which matters enormously when your Italian audience is being served files from a data centre in Virginia. Cloudflare's network has nodes in Milan, Frankfurt, Amsterdam. The difference in TTFB for European visitors is measurable.
One thing to configure explicitly: Cache-Control headers. Static files should have long cache TTLs ( max-age=31536000, immutable for hashed assets). HTML pages should have shorter TTLs or a stale-while-revalidate policy, particularly if you're doing incremental builds after content updates.
Debugging i18n Builds Before They Go Live
A few things I check on every multilingual Astro build before pushing to production:
- Run
astro buildlocally and inspect thedist/output. Every locale folder should have every page. If a route is missing, the build step is the place to catch it. - Use Screaming Frog to crawl the preview deployment. Filter by hreflang. Any orphaned pages (pages not referenced by any hreflang tag) show up immediately.
- Check the
x-defaultcanonical in browser DevTools on at least the homepage and one inner page. - Manually visit a locale URL in a browser set to that locale's language. Sounds obvious. Still catches issues.
The most common build-time issue I hit is forgetting to add a new locale to the astro.config.mjs locales array after adding the JSON file. The build doesn't error. It just silently ignores the new locale. Annoying.
FAQ
Does Astro's i18n work with server-side rendering (SSR) or only static output?
Both. Astro's i18n routing works in SSR mode too. The getRelativeLocaleUrl() and getAbsoluteLocaleUrl() helper functions work identically regardless of output mode. That said, if you're going fully static, the performance benefits are significantly greater because you're serving pre-built HTML with no runtime cost.
How do I handle locale detection and redirects for new visitors?
For static sites I do this at the CDN/edge layer, not inside Astro. Cloudflare Workers can read the Accept-Language header and redirect to the appropriate locale path. Don't do this inside your Astro app because it adds a round trip and makes the static output non-deterministic.
What's the best way to manage translation files as the site grows?
Keep them in version control alongside the code, not in a separate CMS or a third-party translation management system, until the project genuinely warrants it. The moment you have more than three locales and an active editorial team, look at Tolgee or Localazy. Both integrate with flat JSON files and have GitHub sync. Below that threshold, a shared Notion doc and manual JSON updates is honestly fine.
Can I use Astro Content Collections with i18n?
Yes, and it's my preferred approach for blog-heavy sites. Structure your content collection with locale as a path segment: src/content/blog/en/, src/content/blog/de/. Then filter by locale in your getStaticPaths() function. Each locale gets its own static HTML at build time. Clean, fast, and the editorial team can manage markdown files without touching any components.
Does having multiple locale pages hurt SEO through duplicate content?
No, provided your hreflang implementation is correct. Google treats correctly annotated locale variants as related but distinct pages, not as duplicates. The risk comes from incorrectly implementing hreflang, particularly missing reciprocal annotations or incorrect x-default handling. Nail those two things and duplicate content is not a concern.
---
The shift from dynamic PHP-rendered multilingual sites to pre-built static ones isn't really about Astro specifically. It's about accepting that most translation content doesn't change hour to hour. Build it once, cache it everywhere, let the CDN do the heavy lifting. Astro just happens to make that workflow genuinely pleasant rather than a fight against the framework.
Five languages, one developer, eight weeks. The tools are there. Use them.