Skip to content
Development

Two Astro i18n bugs that don't show up until you diff the rendered HTML

By Victor Da Luz
astroi18nseodev-logsite

imperfectsystems.com is getting a Spanish locale, deliberately as a pilot: three posts here versus a hundred and sixty six on vdaluz.com, so any i18n mistakes get discovered on the small site first. Adding i18n: { defaultLocale: 'en', locales: ['en', 'es'], routing: { fallbackType: 'rewrite' } } to Astro’s config and rebuilding was the easy part. It auto-generated working /es/ versions of the homepage, the 404 page, every static page, zero extra route code. I was suspicious of how easy that was. I was right to be.

The fallback doesn’t know what a translation is

The blog posts live in a content collection, src/content/blog/en/hello-dev-log.md and so on, with Spanish translations landing later in es/ via a separate pipeline. My English route filters the collection down to en/ entries and calls getStaticPaths(). I assumed Astro’s fallback would do the same trick it did for the homepage: request /es/blog/hello-dev-log, get the English content back, done.

It does, but the mechanism surprised me. The automatic fallback rewrite works at the URL level - it takes the request, strips the locale prefix, and re-resolves whatever route matches the bare path. For a static page that’s exactly right. For a route with getStaticPaths() reading a locale-split collection, it means /es/blog/<slug> will always render whatever the English route produced, forever, even after a real Spanish translation exists in es/<slug>.md. The fallback has no idea a translation is a thing. It just knows “no route matched, try the default locale’s route instead.”

So I built an explicit /es/blog/[...slug].astro that does its own lookup: prefer the es/ entry, fall back to en/ if it’s not there yet. Same for the paginated index. It’s maybe forty extra lines, but it’s the only way for a future translation to actually show up instead of being silently shadowed by a route fallback that already “solved” the URL.

const byLocale = (loc) =>
  new Map(all.filter((p) => p.id.startsWith(`${loc}/`)).map((p) => [stripPrefix(p.id), p]));

const localized = byLocale(locale);
const fallback = byLocale('en');
const slugs = new Set([...fallback.keys(), ...localized.keys()]);
return [...slugs].map((slug) => localized.get(slug) ?? fallback.get(slug));

Small footnote that cost me a debugging cycle: content collection ids get the locale prefix too (en/hello-dev-log), and a shared card component was building post URLs straight from post.id. First build produced /blog/en/hello-dev-log. I strip the prefix before handing entries to anything that builds a URL - but I keep the original, prefixed entry for Astro’s render() call, since that lookup is keyed by the real id and a stripped clone doesn’t resolve.

The canonical tag that lies about where it lives

This one didn’t show up in the browser at all. The site looked completely normal in both languages. astro check passed. The build succeeded. I only caught it because I’d written down “es/ pages self-canonicalize” as an explicit requirement and decided to actually verify it instead of trusting the pretty page:

grep -o '<link rel="canonical"[^>]*>' dist/client/es/index.html dist/client/index.html

Both files pointed at the same URL. The English one.

The bug: on a page served through the automatic fallback rewrite - the homepage under /es/, for instance - Astro.url.pathname reflects the route that actually executed, which is the English homepage’s route, not the URL the browser requested. Astro.currentLocale gets this right; it correctly says 'es' in the exact same render. Only Astro.url is lying, and only on fallback-rendered pages - the blog routes I’d built explicitly didn’t have this problem, because there the requested path and the matched route are the same thing.

Building the canonical tag from new URL(Astro.url.pathname, Astro.site), which is what the code already did before I touched it, meant every fallback-rendered Spanish page canonicalized itself right back to English. Google would have had two URLs both insisting the English one was the real one. Not broken, just quietly wrong, and wrong in the one place nobody looks because the rendered page gives no visual sign anything’s off.

The fix routes around Astro.url entirely. getAbsoluteLocaleUrl(locale, barePath) from astro:i18n rebuilds the URL from the locale Astro actually resolved plus a stripped bare path, and it doesn’t care whether the current page got here through an explicit route or a fallback rewrite:

const bareLocalePath = Astro.url.pathname.replace(/^\/es(\/|$)/, '/') || '/';
const canonicalURL = getAbsoluteLocaleUrl(locale, bareLocalePath);

What I’d tell past me

“It just worked” on the first build was the tell, not the reassurance. Astro’s i18n fallback is well designed for the case it’s built for - static pages, no per-URL content variation - and it silently stops being the right tool the moment a route has its own idea of what content belongs at a URL. The canonical bug is the sharper lesson: a check that only looks at whether the page renders will never catch a metadata field pointing at the wrong URL. I only found it by treating “es/ pages self-canonicalize” as a thing to grep for, not a thing to eyeball.

Related reading