IntlPull
Tutorial
11 min read

Astro i18n: Build Multilingual Static Sites in 2026

Master Astro internationalization with built-in i18n routing, content collections per locale, and static generation. Learn dynamic routes, @astrojs/starlight integration, and automated workflows with IntlPull.

IntlPull Team
IntlPull Team
Feb 12, 2026
On this page
Summary

Master Astro internationalization with built-in i18n routing, content collections per locale, and static generation. Learn dynamic routes, @astrojs/starlight integration, and automated workflows with IntlPull.

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:

  1. Static-First i18n: Routes pre-generated at build time for zero client-side runtime
  2. File-Based Routing: Locale-specific routes via file structure or dynamic routing
  3. 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:

JavaScript
1import { 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:

  1. prefixDefaultLocale: false/about, /es/about, /fr/about
  2. prefixDefaultLocale: true/en/about, /es/about, /fr/about

Step 2: Create Translation Files

Create src/i18n/locales/en.json:

JSON
1{
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:

JSON
1{
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:

TypeScript
1import 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:

ASTRO
1---
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:

ASTRO
1---
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:

TypeScript
1import { 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:

MARKDOWN
1---
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:

MARKDOWN
1---
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:

ASTRO
1---
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:

ASTRO
1---
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:

ASTRO
1---
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:

Terminal
npm install @astrojs/sitemap

Update astro.config.mjs:

JavaScript
1import 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

Terminal
npm create astro@latest -- --template starlight

Configure Multilingual Docs

Update astro.config.mjs:

JavaScript
1import { 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

Terminal
npm install -g @intlpullhq/cli
cd my-astro-project
intlpull init --framework astro

Configuration (intlpull.config.json):

JSON
1{
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

Terminal
1# 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

YAML
1# .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

Terminal
npm install --save-dev @playwright/test
TypeScript
1import { 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

TypeScript
1import { 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

TypeScript
1// Type-safe translations
2const posts = await getCollection('blog', ({ data }) => {
3  return data.lang === 'en';
4});

2. Prerender All Locales

JavaScript
1export 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

ASTRO
1---
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

JavaScript
1// 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:

TypeScript
1export 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:

TypeScript
1// 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:

JavaScript
export const prerender = false; // Server-rendered route

How do I translate URLs (slugs)?

Map slugs in translation files:

JSON
1{
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:

TypeScript
new 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.

Tags
astro
i18n
localization
static-site
ssg
content-collections
multilingual
IntlPull Team
IntlPull Team
Engineering

Building tools to help teams ship products globally. Follow us for more insights on localization and i18n.