Astro Internationalization (i18n) in 2026: The Complete Guide
Astro 4.0+ has native i18n support. A practical guide to implementing multi-language sites with routing, content organization, and SEO best practices.
TL;DR
- Astro 4.0+ has built-in i18n routing—configure locales in
astro.config.mjsand organize content in/[locale]/folders. - Use
astro:i18nhelper functions for URL generation:getRelativeLocaleUrl(),getAbsoluteLocaleUrl(). - The default locale can be served without a URL prefix (
/aboutinstead of/en/about) for cleaner URLs. - Configure fallback languages for content that doesn’t exist in all locales.
- For content-heavy sites, use Astro’s content collections with locale-based file organization.
- Always implement
hreflangtags and structured data for SEO in multilingual sites. - Starlight (Astro’s documentation theme) has simplified i18n support built in.
Why Astro for Multilingual Sites
Astro’s static-first architecture makes it ideal for multilingual sites:
- Static generation: Each locale gets pre-built HTML, optimal for SEO
- Content collections: Type-safe content management across languages
- Flexible routing: Native i18n routing with minimal configuration
- Performance: Zero JavaScript by default, fast load times globally
- CDN-friendly: Static files distribute easily across global CDNs
The 2026 landscape favors static sites for internationalization—they’re easier to cache, faster to serve globally, and more reliable than client-side translation approaches.
Basic Configuration
Setting Up i18n Routing
Configure your locales in astro.config.mjs:
// astro.config.mjs
import { defineConfig } from 'astro/config';
export default defineConfig({
i18n: {
locales: ['en', 'es', 'de', 'ja'],
defaultLocale: 'en',
},
});
This enables:
/en/about,/es/about,/de/about,/ja/aboutroutes- Locale detection and URL generation helpers
- Middleware for routing logic
Locale Options
| Option | Type | Description |
|---|---|---|
locales | string[] | List of supported locales |
defaultLocale | string | Primary language for fallback |
prefixDefaultLocale | boolean | Whether default locale has URL prefix |
fallback | object | Fallback mapping for missing content |
Serving Default Locale Without Prefix
For cleaner URLs on your primary language:
// astro.config.mjs
export default defineConfig({
i18n: {
locales: ['en', 'es', 'de'],
defaultLocale: 'en',
routing: {
prefixDefaultLocale: false,
},
},
});
Result:
- English:
/about(no prefix) - Spanish:
/es/about - German:
/de/about
Content Organization
File-Based Routing
Organize pages by locale:
src/pages/
├── about.astro # /about (English, default)
├── contact.astro # /contact
├── es/
│ ├── about.astro # /es/about (Spanish)
│ └── contact.astro # /es/contact
├── de/
│ ├── about.astro # /de/about (German)
│ └── contact.astro # /de/contact
Folder names must match your configured locales exactly.
Using Content Collections
For blogs and structured content, organize by locale within collections:
src/content/
├── blog/
│ ├── en/
│ │ ├── post-1.md
│ │ └── post-2.md
│ ├── es/
│ │ ├── post-1.md
│ │ └── post-2.md
│ └── de/
│ └── post-1.md # Not all posts need every locale
Collection schema in src/content/config.ts:
import { defineCollection, z } from 'astro:content';
const blog = defineCollection({
schema: z.object({
title: z.string(),
description: z.string(),
publishedAt: z.string(),
locale: z.enum(['en', 'es', 'de']),
// Reference to original (for translations)
originalSlug: z.string().optional(),
}),
});
export const collections = { blog };
Fetching Localized Content
---
// src/pages/[locale]/blog/[slug].astro
import { getCollection } from 'astro:content';
export async function getStaticPaths() {
const posts = await getCollection('blog');
return posts.map(post => ({
params: {
locale: post.data.locale,
slug: post.slug.replace(`${post.data.locale}/`, ''),
},
props: { post },
}));
}
const { post } = Astro.props;
const { Content } = await post.render();
---
<article>
<h1>{post.data.title}</h1>
<Content />
</article>
URL Generation Helpers
Astro provides astro:i18n utilities for generating locale-aware URLs:
Import and Usage
---
import {
getRelativeLocaleUrl,
getAbsoluteLocaleUrl,
getRelativeLocaleUrlList
} from 'astro:i18n';
// Generate URLs for current page in different locales
const currentPath = Astro.url.pathname;
const enUrl = getRelativeLocaleUrl('en', currentPath);
const esUrl = getRelativeLocaleUrl('es', currentPath);
const deUrl = getRelativeLocaleUrl('de', currentPath);
---
<nav>
<a href={enUrl}>English</a>
<a href={esUrl}>Español</a>
<a href={deUrl}>Deutsch</a>
</nav>
Helper Functions Reference
| Function | Returns | Use Case |
|---|---|---|
getRelativeLocaleUrl(locale, path) | /es/about | Internal links |
getAbsoluteLocaleUrl(locale, path) | https://site.com/es/about | Sitemap, canonical URLs |
getRelativeLocaleUrlList(path) | ['/en/about', '/es/about', ...] | Language switchers |
getAbsoluteLocaleUrlList(path) | Full URLs for all locales | Hreflang tags |
Language Switcher Component
Create a reusable language switcher:
---
// src/components/LanguageSwitcher.astro
import { getRelativeLocaleUrl } from 'astro:i18n';
const locales = [
{ code: 'en', name: 'English', flag: '🇺🇸' },
{ code: 'es', name: 'Español', flag: '🇪🇸' },
{ code: 'de', name: 'Deutsch', flag: '🇩🇪' },
{ code: 'ja', name: '日本語', flag: '🇯🇵' },
];
// Get current path without locale prefix
const currentPath = Astro.url.pathname.replace(/^\/(en|es|de|ja)/, '') || '/';
const currentLocale = Astro.currentLocale ?? 'en';
---
<nav class="language-switcher" aria-label="Language selection">
<button class="current-language" aria-expanded="false">
{locales.find(l => l.code === currentLocale)?.flag}
{locales.find(l => l.code === currentLocale)?.name}
</button>
<ul class="language-options">
{locales.map(locale => (
<li>
<a
href={getRelativeLocaleUrl(locale.code, currentPath)}
aria-current={locale.code === currentLocale ? 'page' : undefined}
>
{locale.flag} {locale.name}
</a>
</li>
))}
</ul>
</nav>
<style>
.language-switcher {
position: relative;
}
.language-options {
display: none;
position: absolute;
top: 100%;
right: 0;
background: var(--color-clay);
border-radius: 0.5rem;
padding: 0.5rem;
list-style: none;
}
.language-switcher:focus-within .language-options,
.language-switcher:hover .language-options {
display: block;
}
.language-options a {
display: block;
padding: 0.5rem 1rem;
text-decoration: none;
white-space: nowrap;
}
.language-options a[aria-current="page"] {
font-weight: bold;
}
</style>
SEO for Multilingual Sites
Hreflang Tags
Critical for telling search engines about language variations:
---
// src/layouts/BaseLayout.astro
import { getAbsoluteLocaleUrlList } from 'astro:i18n';
const locales = ['en', 'es', 'de', 'ja'];
const currentPath = Astro.url.pathname.replace(/^\/(en|es|de|ja)/, '') || '/';
const currentLocale = Astro.currentLocale ?? 'en';
---
<head>
<!-- Hreflang tags -->
{locales.map(locale => (
<link
rel="alternate"
hreflang={locale}
href={new URL(getRelativeLocaleUrl(locale, currentPath), Astro.site)}
/>
))}
<link
rel="alternate"
hreflang="x-default"
href={new URL(currentPath, Astro.site)}
/>
<!-- Canonical for current locale -->
<link rel="canonical" href={Astro.url.href} />
</head>
Structured Data
Add locale information to schema.org data:
---
const articleSchema = {
"@context": "https://schema.org",
"@type": "Article",
"headline": title,
"inLanguage": Astro.currentLocale ?? 'en',
// ... other properties
};
---
<script type="application/ld+json" set:html={JSON.stringify(articleSchema)} />
Sitemap Configuration
Update astro.config.mjs for multilingual sitemaps:
import sitemap from '@astrojs/sitemap';
export default defineConfig({
site: 'https://example.com',
integrations: [
sitemap({
i18n: {
defaultLocale: 'en',
locales: {
en: 'en-US',
es: 'es-ES',
de: 'de-DE',
ja: 'ja-JP',
},
},
}),
],
});
Fallback Content Strategy
Configuring Fallbacks
When content doesn’t exist in a locale, fall back to another:
// astro.config.mjs
export default defineConfig({
i18n: {
locales: ['en', 'es', 'de', 'ja'],
defaultLocale: 'en',
fallback: {
es: 'en', // Spanish falls back to English
de: 'en', // German falls back to English
ja: 'en', // Japanese falls back to English
},
},
});
Showing Fallback Indicator
Let users know they’re viewing translated or fallback content:
---
const { post } = Astro.props;
const currentLocale = Astro.currentLocale ?? 'en';
const isFallback = post.data.locale !== currentLocale;
---
{isFallback && (
<div class="fallback-notice">
This content is not available in {currentLocale}.
Showing {post.data.locale} version.
</div>
)}
RTL Language Support
For right-to-left languages (Arabic, Hebrew, Farsi):
---
const rtlLocales = ['ar', 'he', 'fa'];
const currentLocale = Astro.currentLocale ?? 'en';
const isRtl = rtlLocales.includes(currentLocale);
---
<html lang={currentLocale} dir={isRtl ? 'rtl' : 'ltr'}>
Add RTL-aware CSS:
/* Using logical properties for RTL support */
.content {
padding-inline-start: 2rem;
margin-inline-end: 1rem;
}
/* RTL-specific overrides if needed */
[dir="rtl"] .icon {
transform: scaleX(-1);
}
Translation Workflow
String Extraction
For UI strings, create a translation system:
// src/i18n/translations.ts
export const translations = {
en: {
nav: {
home: 'Home',
about: 'About',
contact: 'Contact',
},
common: {
readMore: 'Read more',
share: 'Share',
},
},
es: {
nav: {
home: 'Inicio',
about: 'Acerca de',
contact: 'Contacto',
},
common: {
readMore: 'Leer más',
share: 'Compartir',
},
},
// ... other locales
};
export function t(locale: string, key: string): string {
const keys = key.split('.');
let value: any = translations[locale] || translations['en'];
for (const k of keys) {
value = value?.[k];
}
return value || key;
}
Usage in components:
---
import { t } from '../i18n/translations';
const locale = Astro.currentLocale ?? 'en';
---
<nav>
<a href="/">{t(locale, 'nav.home')}</a>
<a href="/about">{t(locale, 'nav.about')}</a>
</nav>
Implementation Checklist
- Configure
i18ninastro.config.mjs - Organize content by locale folders
- Create language switcher component
- Implement hreflang tags in base layout
- Set up canonical URLs per locale
- Configure sitemap for multiple languages
- Create translation system for UI strings
- Add RTL support if needed
- Set up fallback content strategy
- Test all routes in each locale
- Verify SEO tags with structured data testing
FAQ
Should I use subdomains or subdirectories for locales?
Subdirectories (/es/about) are recommended for most sites. They’re easier to manage, share link equity, and work well with Astro’s built-in routing. Subdomains (es.example.com) are better for targeting specific countries with different content strategies.
How do I handle content that shouldn’t be translated?
Use the fallback system—if content doesn’t exist in a locale, Astro serves the fallback. You can also explicitly mark content as “universal” and serve the same content across all locales.
What about SEO for pages with no translation?
If a page only exists in one language, don’t generate URLs for other locales. This prevents thin content issues. Use getStaticPaths to control which locale/page combinations exist.
How do I detect the user’s preferred language?
Astro middleware can read Accept-Language headers and redirect accordingly. However, for static sites, consider defaulting to your primary language and letting users switch—over-redirecting based on IP can frustrate users.
Should I translate URLs/slugs?
For blog posts and key landing pages, translated slugs help SEO (/es/como-validar-idea vs /es/how-to-validate-idea). For utility pages, keeping English slugs is acceptable and easier to maintain.
How does Starlight handle i18n?
Starlight (Astro’s documentation theme) has built-in i18n with simplified configuration, automatic sidebar translation, and language switching. If you’re building documentation, start with Starlight rather than custom implementation.
Sources & Further Reading
- Astro i18n Routing Documentation — Official guide
- Astro i18n API Reference — Helper functions
- Starlight i18n Guide — Documentation theme
- Adding i18n Features to Astro — Recipes and patterns
- Crowdin Astro i18n Guide — Localization workflow
- AEO Content Strategy — Related: optimizing for AI engines
- Image SEO Optimization — Related: multilingual image SEO
Interested in our research?
We share our work openly. If you'd like to collaborate or discuss ideas — we'd love to hear from you.
Get in Touch