Back to blog
Engineering #astro#i18n#internationalization

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.

14 min · January 14, 2026 · Updated January 27, 2026
Topic relevant background image

TL;DR

  • Astro 4.0+ has built-in i18n routing—configure locales in astro.config.mjs and organize content in /[locale]/ folders.
  • Use astro:i18n helper functions for URL generation: getRelativeLocaleUrl(), getAbsoluteLocaleUrl().
  • The default locale can be served without a URL prefix (/about instead 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 hreflang tags 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/about routes
  • Locale detection and URL generation helpers
  • Middleware for routing logic

Locale Options

OptionTypeDescription
localesstring[]List of supported locales
defaultLocalestringPrimary language for fallback
prefixDefaultLocalebooleanWhether default locale has URL prefix
fallbackobjectFallback 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

FunctionReturnsUse Case
getRelativeLocaleUrl(locale, path)/es/aboutInternal links
getAbsoluteLocaleUrl(locale, path)https://site.com/es/aboutSitemap, canonical URLs
getRelativeLocaleUrlList(path)['/en/about', '/es/about', ...]Language switchers
getAbsoluteLocaleUrlList(path)Full URLs for all localesHreflang 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 i18n in astro.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

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

Let's build
something real.

No more slide decks. No more "maybe next quarter".
Let's ship your MVP in weeks.

Start Building Now