Astro's island architecture and static-first approach make it an exceptional choice for building fast, SEO-optimized multilingual websites. With Astro 4.0's built-in internationalization routing, content collections per locale, and zero-JavaScript default output, you can create lightning-fast multilingual sites with perfect Lighthouse scores. This comprehensive guide covers Astro's native i18n routing, content collections strategy, dynamic route generation, @astrojs/starlight integration for documentation sites, URL structure patterns, and integration with IntlPull for automated translation management and team collaboration workflows.
Understanding Astro's i18n Architecture
Astro's i18n system is built around three core principles:
- Static-First i18n: Routes pre-generated at build time for zero client-side runtime
- File-Based Routing: Locale-specific routes via file structure or dynamic routing
- Content Collections: Type-safe, locale-specific content management
This architecture ensures perfect SEO (all content rendered server-side), zero JavaScript overhead (no client-side locale switching), and exceptional performance (pre-rendered static HTML).
Basic Setup with Built-in i18n
Step 1: Configure i18n Routing
Update astro.config.mjs:
JavaScript1import { defineConfig } from 'astro/config'; 2 3export default defineConfig({ 4 i18n: { 5 defaultLocale: 'en', 6 locales: ['en', 'es', 'fr'], 7 routing: { 8 prefixDefaultLocale: false, // /about (en), /es/acerca (es) 9 }, 10 }, 11});
Routing Strategies:
prefixDefaultLocale: false→/about,/es/about,/fr/aboutprefixDefaultLocale: true→/en/about,/es/about,/fr/about
Step 2: Create Translation Files
Create src/i18n/locales/en.json:
JSON1{ 2 "nav": { 3 "home": "Home", 4 "about": "About", 5 "blog": "Blog" 6 }, 7 "home": { 8 "title": "Welcome to Our Site", 9 "subtitle": "Built with Astro", 10 "cta": "Get Started" 11 }, 12 "footer": { 13 "copyright": "© 2026 All rights reserved" 14 } 15}
Create src/i18n/locales/es.json:
JSON1{ 2 "nav": { 3 "home": "Inicio", 4 "about": "Acerca de", 5 "blog": "Blog" 6 }, 7 "home": { 8 "title": "Bienvenido a Nuestro Sitio", 9 "subtitle": "Construido con Astro", 10 "cta": "Comenzar" 11 }, 12 "footer": { 13 "copyright": "© 2026 Todos los derechos reservados" 14 } 15}
Step 3: Create Translation Utility
Create src/i18n/utils.ts:
TypeScript1import en from './locales/en.json'; 2import es from './locales/es.json'; 3import fr from './locales/fr.json'; 4 5export const languages = { 6 en: 'English', 7 es: 'Español', 8 fr: 'Français', 9}; 10 11export const defaultLang = 'en'; 12 13export const translations = { 14 en, 15 es, 16 fr, 17}; 18 19export function getLangFromUrl(url: URL) { 20 const [, lang] = url.pathname.split('/'); 21 if (lang in translations) return lang as keyof typeof translations; 22 return defaultLang; 23} 24 25export function useTranslations(lang: keyof typeof translations) { 26 return function t(key: string) { 27 const keys = key.split('.'); 28 let value: any = translations[lang]; 29 30 for (const k of keys) { 31 value = value?.[k]; 32 } 33 34 return value || key; 35 }; 36} 37 38export function useTranslatedPath(lang: keyof typeof translations) { 39 return function translatePath(path: string, l: string = lang) { 40 return l === defaultLang ? path : `/${l}${path}`; 41 }; 42}
Step 4: Create Localized Pages
Create src/pages/index.astro:
ASTRO1--- 2import { getLangFromUrl, useTranslations } from '../i18n/utils'; 3 4const lang = getLangFromUrl(Astro.url); 5const t = useTranslations(lang); 6--- 7 8<html lang={lang}> 9 <head> 10 <title>{t('home.title')}</title> 11 </head> 12 <body> 13 <nav> 14 <a href="/">{t('nav.home')}</a> 15 <a href="/about">{t('nav.about')}</a> 16 <a href="/blog">{t('nav.blog')}</a> 17 </nav> 18 19 <main> 20 <h1>{t('home.title')}</h1> 21 <p>{t('home.subtitle')}</p> 22 <a href="/get-started">{t('home.cta')}</a> 23 </main> 24 25 <footer> 26 <p>{t('footer.copyright')}</p> 27 </footer> 28 </body> 29</html>
Create src/pages/es/index.astro:
ASTRO1--- 2import { getLangFromUrl, useTranslations } from '../../i18n/utils'; 3 4const lang = getLangFromUrl(Astro.url); 5const t = useTranslations(lang); 6--- 7 8<html lang={lang}> 9 <head> 10 <title>{t('home.title')}</title> 11 </head> 12 <body> 13 <!-- Same structure as English version --> 14 <h1>{t('home.title')}</h1> 15 </body> 16</html>
Content Collections for Multilingual Content
Step 1: Define Content Collections
Create src/content/config.ts:
TypeScript1import { defineCollection, z } from 'astro:content'; 2 3const blog = defineCollection({ 4 type: 'content', 5 schema: z.object({ 6 title: z.string(), 7 description: z.string(), 8 pubDate: z.date(), 9 author: z.string(), 10 lang: z.enum(['en', 'es', 'fr']), 11 }), 12}); 13 14export const collections = { blog };
Step 2: Create Localized Content
Create src/content/blog/en/first-post.md:
MARKDOWN1--- 2title: 'First Post' 3description: 'This is our first blog post' 4pubDate: 2026-02-12 5author: 'John Doe' 6lang: 'en' 7--- 8 9Welcome to our blog! This is the first post.
Create src/content/blog/es/first-post.md:
MARKDOWN1--- 2title: 'Primera Publicación' 3description: 'Esta es nuestra primera publicación' 4pubDate: 2026-02-12 5author: 'John Doe' 6lang: 'es' 7--- 8 9¡Bienvenido a nuestro blog! Esta es la primera publicación.
Step 3: Generate Dynamic Routes
Create src/pages/blog/[...slug].astro:
ASTRO1--- 2import { getCollection } from 'astro:content'; 3import { getLangFromUrl, useTranslations } from '../../i18n/utils'; 4 5export async function getStaticPaths() { 6 const blogPosts = await getCollection('blog'); 7 8 return blogPosts.map((post) => ({ 9 params: { slug: post.slug }, 10 props: { post }, 11 })); 12} 13 14const { post } = Astro.props; 15const { Content } = await post.render(); 16const lang = post.data.lang; 17const t = useTranslations(lang); 18--- 19 20<html lang={lang}> 21 <head> 22 <title>{post.data.title}</title> 23 <meta name="description" content={post.data.description} /> 24 </head> 25 <body> 26 <article> 27 <h1>{post.data.title}</h1> 28 <p>{post.data.description}</p> 29 <time datetime={post.data.pubDate.toISOString()}> 30 {post.data.pubDate.toLocaleDateString(lang)} 31 </time> 32 <Content /> 33 </article> 34 </body> 35</html>
Language Switcher Component
Create src/components/LanguageSwitcher.astro:
ASTRO1--- 2import { languages, getLangFromUrl } from '../i18n/utils'; 3 4const currentLang = getLangFromUrl(Astro.url); 5const currentPath = Astro.url.pathname.replace(/^/(en|es|fr)/, '') || '/'; 6--- 7 8<div class="language-switcher"> 9 {Object.entries(languages).map(([lang, label]) => ( 10 <a 11 href={lang === 'en' ? currentPath : `/${lang}${currentPath}`} 12 class:list={[{ active: lang === currentLang }]} 13 hreflang={lang} 14 > 15 {label} 16 </a> 17 ))} 18</div> 19 20<style> 21 .language-switcher { 22 display: flex; 23 gap: 1rem; 24 } 25 26 .active { 27 font-weight: bold; 28 text-decoration: underline; 29 } 30</style>
SEO Optimization
Hreflang Tags
Create src/components/SEOHead.astro:
ASTRO1--- 2interface Props { 3 title: string; 4 description: string; 5 lang: string; 6 canonicalURL: URL; 7} 8 9const { title, description, lang, canonicalURL } = Astro.props; 10 11const alternateURLs = { 12 en: new URL(canonicalURL.pathname.replace(/^/(es|fr)/, ''), canonicalURL.origin), 13 es: new URL(`/es${canonicalURL.pathname.replace(/^/(es|fr)/, '')}`, canonicalURL.origin), 14 fr: new URL(`/fr${canonicalURL.pathname.replace(/^/(es|fr)/, '')}`, canonicalURL.origin), 15}; 16--- 17 18<head> 19 <meta charset="UTF-8" /> 20 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 21 <title>{title}</title> 22 <meta name="description" content={description} /> 23 <link rel="canonical" href={canonicalURL} /> 24 25 {Object.entries(alternateURLs).map(([hreflang, url]) => ( 26 <link rel="alternate" hreflang={hreflang} href={url.toString()} /> 27 ))} 28 <link rel="alternate" hreflang="x-default" href={alternateURLs.en.toString()} /> 29</head>
Sitemap with Locales
Install Astro sitemap:
Terminalnpm install @astrojs/sitemap
Update astro.config.mjs:
JavaScript1import sitemap from '@astrojs/sitemap'; 2 3export default defineConfig({ 4 site: 'https://example.com', 5 integrations: [sitemap({ 6 i18n: { 7 defaultLocale: 'en', 8 locales: { 9 en: 'en', 10 es: 'es', 11 fr: 'fr', 12 }, 13 }, 14 })], 15});
Starlight Integration for Documentation
Setup Starlight
Terminalnpm create astro@latest -- --template starlight
Configure Multilingual Docs
Update astro.config.mjs:
JavaScript1import { defineConfig } from 'astro/config'; 2import starlight from '@astrojs/starlight'; 3 4export default defineConfig({ 5 integrations: [ 6 starlight({ 7 title: 'My Docs', 8 defaultLocale: 'en', 9 locales: { 10 en: { label: 'English' }, 11 es: { label: 'Español' }, 12 fr: { label: 'Français' }, 13 }, 14 sidebar: [ 15 { 16 label: 'Guides', 17 translations: { 18 es: 'Guías', 19 fr: 'Guides', 20 }, 21 items: [ 22 { label: 'Getting Started', link: '/guides/getting-started/' }, 23 ], 24 }, 25 ], 26 }), 27 ], 28});
Create Localized Docs
src/content/docs/
├── en/
│ └── guides/
│ └── getting-started.md
├── es/
│ └── guides/
│ └── getting-started.md
└── fr/
└── guides/
└── getting-started.md
IntlPull CLI Integration
Installation
Terminalnpm install -g @intlpullhq/cli cd my-astro-project intlpull init --framework astro
Configuration (intlpull.config.json):
JSON1{ 2 "projectId": "your-project-id", 3 "apiKey": "ip_live_...", 4 "framework": "astro", 5 "sourcePath": "src/i18n/locales", 6 "format": "json", 7 "languages": ["en", "es", "fr"], 8 "defaultLanguage": "en" 9}
Automated Workflows
Terminal1# Extract strings from .astro files 2intlpull scan src/pages --auto-wrap 3 4# Push translations to IntlPull 5intlpull push 6 7# Pull latest translations 8intlpull pull 9 10# Watch for remote changes 11intlpull watch
GitHub Actions Integration
YAML1# .github/workflows/i18n-sync.yml 2name: Sync Translations 3on: 4 push: 5 paths: 6 - 'src/i18n/locales/**' 7jobs: 8 sync: 9 runs-on: ubuntu-latest 10 steps: 11 - uses: actions/checkout@v3 12 - run: npm install -g @intlpullhq/cli 13 - run: intlpull push 14 env: 15 INTLPULL_API_KEY: ${{ secrets.INTLPULL_API_KEY }}
Testing Localization
Visual Regression Testing
Terminalnpm install --save-dev @playwright/test
TypeScript1import { test, expect } from '@playwright/test'; 2 3test('homepage renders in Spanish', async ({ page }) => { 4 await page.goto('/es'); 5 await expect(page.locator('h1')).toContainText('Bienvenido'); 6 await expect(page).toHaveScreenshot('homepage-es.png'); 7});
Content Validation
TypeScript1import { getCollection } from 'astro:content'; 2 3const blogPosts = await getCollection('blog'); 4const languages = ['en', 'es', 'fr']; 5 6languages.forEach((lang) => { 7 const posts = blogPosts.filter((p) => p.data.lang === lang); 8 console.log(`${lang}: ${posts.length} posts`); 9});
Best Practices
1. Use Content Collections for All Content
TypeScript1// Type-safe translations 2const posts = await getCollection('blog', ({ data }) => { 3 return data.lang === 'en'; 4});
2. Prerender All Locales
JavaScript1export const prerender = true; 2 3export async function getStaticPaths() { 4 return [ 5 { params: { lang: 'en' } }, 6 { params: { lang: 'es' } }, 7 { params: { lang: 'fr' } }, 8 ]; 9}
3. Optimize Images Per Locale
ASTRO1--- 2import { Image } from 'astro:assets'; 3import heroEn from '../assets/hero-en.jpg'; 4import heroEs from '../assets/hero-es.jpg'; 5 6const images = { en: heroEn, es: heroEs }; 7const lang = getLangFromUrl(Astro.url); 8--- 9 10<Image src={images[lang]} alt={t('hero.alt')} />
4. Cache-Control Headers
JavaScript1// astro.config.mjs 2export default defineConfig({ 3 vite: { 4 build: { 5 assetsInlineLimit: 0, 6 }, 7 }, 8});
Common Pitfalls
Issue: Default Locale 404s
Cause: prefixDefaultLocale: false but created /en/index.astro.
Solution: Create /index.astro (not /en/index.astro) for default locale.
Issue: Missing Translations Fallback
Cause: No fallback logic in useTranslations.
Solution:
TypeScript1export function useTranslations(lang: keyof typeof translations) { 2 return function t(key: string) { 3 const keys = key.split('.'); 4 let value: any = translations[lang]; 5 6 for (const k of keys) { 7 value = value?.[k]; 8 } 9 10 // Fallback to English 11 if (!value && lang !== 'en') { 12 let fallback: any = translations.en; 13 for (const k of keys) { 14 fallback = fallback?.[k]; 15 } 16 return fallback || key; 17 } 18 19 return value || key; 20 }; 21}
Production Deployment Checklist
- All routes pre-rendered with
getStaticPaths - Hreflang tags added to all pages
- Sitemap generated with locales
- Content collections validated per language
- IntlPull CLI integrated in CI/CD
- 404 pages translated
- Language switcher tested
- Images optimized per locale
Frequently Asked Questions
How do I handle user-generated content?
Use API routes for dynamic content:
TypeScript1// src/pages/api/comments.json.ts 2export async function GET({ request }) { 3 const url = new URL(request.url); 4 const lang = url.searchParams.get('lang') || 'en'; 5 const comments = await db.getComments(lang); 6 return new Response(JSON.stringify(comments)); 7}
Can I mix static and dynamic routes?
Yes, use hybrid rendering:
JavaScriptexport const prerender = false; // Server-rendered route
How do I translate URLs (slugs)?
Map slugs in translation files:
JSON1{ 2 "slugs": { 3 "about": "acerca-de", 4 "contact": "contacto" 5 } 6}
Does IntlPull support Astro content collections?
Yes, IntlPull CLI can export to Markdown frontmatter or JSON for content collections.
How do I handle date/number formatting?
Use Intl API:
TypeScriptnew Intl.DateTimeFormat(lang).format(new Date());
Conclusion
Astro's static-first i18n architecture provides unmatched performance and SEO for multilingual sites. By leveraging built-in routing, content collections, and zero-JavaScript delivery, you create lightning-fast experiences for global audiences. Integration with IntlPull streamlines translation management, enabling automated workflows and team collaboration without sacrificing build speed or site performance.
Start with built-in i18n routing, adopt content collections for type safety, and integrate IntlPull CLI for automated translation management. Your global users will appreciate the fast, localized experience, and your team will appreciate the simplified workflow.
Ready to build a multilingual Astro site? Try IntlPull free with 500 keys and 3 languages, or explore our Astro documentation for advanced patterns.
