Tutorials

Astro + i18n: Building Multi-Language Static and Dynamic Sites

Eray Gündoğmuş
Eray Gündoğmuş
·10 min read
Share
Astro + i18n: Building Multi-Language Static and Dynamic Sites

Astro + i18n: Building Multi-Language Static and Dynamic Sites

If you've shipped an Astro site and then gotten the request to "add French and German," you know the feeling: a brief moment of hope followed by the realization that the docs, the community libraries, and the blog posts you can find are all slightly contradictory. Some use file-based routing. Some use a library that wraps something else. Some were written for Astro 2.x when you're now on 4.x.

This post is an attempt to fix that. We'll walk through Astro's built-in i18n routing, when to reach for a community library, how to connect an external translation system, and where things can go wrong. The goal is a single, honest reference for developers building multi-language sites on Astro — whether you're doing static generation, server-side rendering, or a hybrid of both.


Why Astro for Multi-Language Sites

Astro's architecture makes it a natural fit for localized content. The framework defaults to zero client-side JavaScript, which means your translated content ships as plain HTML — fast to load, easy to index, no hydration delay for users in Tokyo or Berlin.

The file-based routing model maps neatly to locale-prefixed URLs like /fr/about or /de/pricing. And because Astro supports both static generation (SSG) and server-side rendering (SSR) within the same project, you can pre-render pages with stable translations and still handle dynamic content through server endpoints.

That said, Astro's i18n story has matured through several major versions, and the guidance you find on older posts may not apply to what you're working with today.


Built-in i18n Routing (Astro 3.x+)

Astro introduced first-class i18n routing in version 3.0. Before that, every team was rolling their own locale detection and URL management. The built-in system handles the boilerplate and integrates with Astro's adapter layer for SSR.

astro.config.mjs Setup

The entry point is your Astro config file:

// astro.config.mjs
import { defineConfig } from 'astro/config';

export default defineConfig({
  i18n: {
    defaultLocale: 'en',
    locales: ['en', 'fr', 'de', 'ja'],
    routing: {
      prefixDefaultLocale: false,
    },
  },
});

A few things worth understanding here:

  • defaultLocale is the language your site falls back to. It does not need to appear in the URL if prefixDefaultLocale is false — so /about serves English while /fr/about serves French.
  • locales is an array of BCP 47 language tags. You can also pass objects if you need custom path segments (e.g., mapping pt-BR to /br/).
  • prefixDefaultLocale: false is the most common production setup. Setting it to true gives you /en/about instead of /about, which can help with SEO clarity at the cost of longer URLs.

File Structure

With i18n routing enabled, Astro expects your localized pages under src/pages/:

src/
  pages/
    index.astro          ← Default locale (English)
    about.astro
    fr/
      index.astro        ← French
      about.astro
    de/
      index.astro        ← German
      about.astro
    ja/
      index.astro        ← Japanese
      about.astro

This is the simplest possible setup. Each file is a standalone page with its own content. No shared components required — though in practice, you'll want them.


One of the more useful helpers Astro exposes is getRelativeLocaleUrl. It takes a path and a locale and returns the correctly prefixed URL, accounting for whether the default locale has a prefix or not.

---
// src/pages/fr/index.astro
import { getRelativeLocaleUrl } from 'astro:i18n';

const aboutUrl = getRelativeLocaleUrl('fr', '/about');
// Returns: /fr/about
---

<a href={aboutUrl}>À propos</a>

You can also use getAbsoluteLocaleUrl if you need full URLs for sitemaps or OG tags:

---
import { getAbsoluteLocaleUrl } from 'astro:i18n';

const canonicalUrl = getAbsoluteLocaleUrl('fr', '/about');
// Returns: https://yoursite.com/fr/about
---

Using these helpers instead of hardcoded strings means your link structure survives configuration changes — like switching prefixDefaultLocale from false to true — without a find-and-replace across your entire codebase.


Fallback Content

Not every page will be translated into every language on day one. Astro handles this with fallback configuration:

// astro.config.mjs
export default defineConfig({
  i18n: {
    defaultLocale: 'en',
    locales: ['en', 'fr', 'de', 'ja'],
    fallback: {
      fr: 'en',
      de: 'en',
      ja: 'en',
    },
    routing: {
      prefixDefaultLocale: false,
      fallbackType: 'rewrite',
    },
  },
});

With fallbackType: 'rewrite', Astro will serve the English content at the /fr/about URL if src/pages/fr/about.astro does not exist — without redirecting the user. The URL stays as /fr/about but the content comes from English. This is useful for soft launches where you're rolling out translations incrementally.

Alternatively, fallbackType: 'redirect' will send the user to /about if the French version is missing, which is more transparent but creates a jarring experience.


Custom Domain Mapping

If your locales live on separate domains rather than URL prefixes — a common enterprise pattern — Astro supports this through i18n.domains:

// astro.config.mjs
export default defineConfig({
  site: 'https://example.com',
  i18n: {
    defaultLocale: 'en',
    locales: ['en', 'fr', 'de'],
    domains: {
      fr: 'https://fr.example.com',
      de: 'https://de.example.com',
    },
  },
  output: 'server',
  adapter: node({ mode: 'standalone' }),
});

Note the output: 'server' requirement. Domain mapping only works with SSR because Astro needs to inspect the incoming request hostname at runtime to determine which locale to serve.

The getAbsoluteLocaleUrl helper respects domain mapping:

---
import { getAbsoluteLocaleUrl } from 'astro:i18n';

const frenchHome = getAbsoluteLocaleUrl('fr', '/');
// Returns: https://fr.example.com/
---

Managing Translation Strings

Built-in routing handles URLs and page resolution. It does not handle translation strings — the actual translated text in your UI components. For that, you have several options.

Option 1: Simple JSON Files (No Library)

For small sites, a plain JSON approach works fine:

src/
  i18n/
    en.json
    fr.json
    de.json
// src/i18n/en.json
{
  "nav.home": "Home",
  "nav.about": "About",
  "hero.title": "Build faster websites",
  "hero.subtitle": "The web framework for content-driven sites"
}
---
// src/pages/fr/index.astro
const locale = 'fr';
const t = (await import(`../../i18n/${locale}.json`)).default;
---

<h1>{t['hero.title']}</h1>
<p>{t['hero.subtitle']}</p>

This works, but you'll feel the pain as the site grows: no type safety, no missing-key warnings, manual imports everywhere.

Option 2: A Utility Function

A small wrapper makes things more manageable:

// src/i18n/utils.ts
import en from './en.json';
import fr from './fr.json';
import de from './de.json';

const translations = { en, fr, de } as const;

type Locale = keyof typeof translations;
type TranslationKey = keyof typeof en;

export function useTranslations(locale: Locale) {
  return function t(key: TranslationKey): string {
    return translations[locale][key] ?? translations['en'][key] ?? key;
  };
}
---
// src/pages/fr/index.astro
import { useTranslations } from '../../i18n/utils';

const t = useTranslations('fr');
---

<h1>{t('hero.title')}</h1>

Now you have TypeScript autocomplete on translation keys and a fallback to English for missing strings. This is the pattern Astro's official i18n recipe recommends, and it covers a lot of ground without adding a dependency.


Community Libraries

When the built-in approach isn't enough, the ecosystem offers a few options worth knowing:

astro-i18next

Built on top of i18next, this is the most feature-rich option:

npm install astro-i18next i18next
// astro.config.mjs
import { defineConfig } from 'astro/config';
import astroI18next from 'astro-i18next';

export default defineConfig({
  integrations: [astroI18next()],
});
// astro-i18next.config.mjs
export const defaultLocale = 'en';
export const locales = ['en', 'fr', 'de'];

You get pluralization, interpolation, namespace support, and the full i18next ecosystem. The trade-off is complexity — i18next has a lot of surface area, and debugging SSR edge cases with it can be frustrating.

Paraglide JS (Inlang)

Paraglide takes a different approach: it generates type-safe message functions at build time rather than resolving strings at runtime. This means zero runtime overhead for translations and full TypeScript safety.

npm install @inlang/paraglide-astro
// astro.config.mjs
import { defineConfig } from 'astro/config';
import paraglide from '@inlang/paraglide-astro';

export default defineConfig({
  integrations: [
    paraglide({
      project: './project.inlang',
      outdir: './src/paraglide',
    }),
  ],
});
// Generated at build time — fully typed
import * as m from '../paraglide/messages';

// Autocomplete works, typos are compile errors
const title = m.hero_title();

Paraglide is worth serious consideration if TypeScript correctness matters to your team. The inlang ecosystem is growing and the tooling is actively maintained.

intlayer

Intlayer colocates translations with components rather than in separate locale files:

// src/components/Hero.content.ts
import { t, type DeclarationContent } from 'intlayer';

const heroContent = {
  id: 'hero',
  title: t({
    en: 'Build faster websites',
    fr: 'Construisez des sites plus rapides',
    de: 'Bauen Sie schnellere Websites',
  }),
} satisfies DeclarationContent;

export default heroContent;

This colocation model reduces the cognitive overhead of matching component logic to translation keys. It's a good fit for component-heavy projects where content and layout evolve together.


Integrating an External TMS with Astro Builds

Most production localization workflows involve a translation management system (TMS) — a platform where translators, reviewers, and project managers collaborate. Astro's build step is the natural integration point.

The typical pattern:

  1. Export source strings from your codebase to the TMS.
  2. Translators work in the TMS UI (or AI translation runs there).
  3. Translated strings are pulled back into your repo or fetched at build time.
  4. Astro builds the site with the current translations.

For CI/CD, this means your build pipeline might look like:

# .github/workflows/deploy.yml
jobs:
  build:
    steps:
      - uses: actions/checkout@v4
      - name: Pull translations
        run: npx better-i18n pull --all-locales
        env:
          BETTER_I18N_API_KEY: ${{ secrets.BETTER_I18N_API_KEY }}
      - name: Build site
        run: npm run build
      - name: Deploy
        run: npm run deploy

The pull step fetches the latest approved translations before the build runs. Platforms like Better i18n support this pattern through CDN delivery — you can either pull locale files at build time for SSG or fetch them at runtime from the edge for SSR, depending on your deployment model.


Where Astro i18n Gets Complicated

A few areas where teams consistently run into trouble:

Type Safety for Translation Keys

File-based JSON translations have no compile-time guarantees. A typo in a key silently returns undefined or falls through to the key string. The utility function approach with TypeScript helps, but requires discipline to maintain as the key set grows.

This is where tooling like Better i18n adds value — the platform's SDKs generate TypeScript types from your translation schema, so missing or mistyped keys fail at compile time rather than silently breaking in production.

SEO Metadata Per Locale

Each locale needs its own <title>, <meta name="description">, and canonical URL. This is easy to forget and hard to audit manually. Getting these right is one of the core items in any international SEO checklist — particularly the canonical and hreflang tags that tell search engines how your locales relate to each other.

A shared layout component helps:

---
// src/layouts/Base.astro
import { getAbsoluteLocaleUrl } from 'astro:i18n';

interface Props {
  locale: string;
  title: string;
  description: string;
}

const { locale, title, description } = Astro.props;
const canonical = getAbsoluteLocaleUrl(locale, Astro.url.pathname);
---

<html lang={locale}>
  <head>
    <title>{title}</title>
    <meta name="description" content={description} />
    <link rel="canonical" href={canonical} />
    <link rel="alternate" hreflang="x-default" href={getAbsoluteLocaleUrl('en', Astro.url.pathname)} />
    <link rel="alternate" hreflang="fr" href={getAbsoluteLocaleUrl('fr', Astro.url.pathname)} />
    <link rel="alternate" hreflang="de" href={getAbsoluteLocaleUrl('de', Astro.url.pathname)} />
  </head>
  <body>
    <slot />
  </body>
</html>

Hreflang tags are particularly important for Google to understand your locale structure. Missing or incorrect hreflang is one of the most common SEO mistakes on multi-language sites. If your site targets Spanish-speaking audiences across multiple regions, the guide on hreflang for Spanish markets covers the exact regional variant codes and reciprocal linking requirements.

Sitemap Generation

Astro's @astrojs/sitemap integration supports i18n, but you need to configure it explicitly:

// astro.config.mjs
import sitemap from '@astrojs/sitemap';

export default defineConfig({
  site: 'https://yoursite.com',
  integrations: [
    sitemap({
      i18n: {
        defaultLocale: 'en',
        locales: {
          en: 'en-US',
          fr: 'fr-FR',
          de: 'de-DE',
        },
      },
    }),
  ],
});

This generates a sitemap with <xhtml:link> hreflang alternates for each URL — exactly what search engines need to index your localized content correctly.

Content Collections with Localization

If you're using Astro's Content Collections for blog posts or documentation, localization adds another dimension. A common pattern is to separate content by locale in the collection directory:

src/
  content/
    blog/
      en/
        getting-started.md
        advanced-config.md
      fr/
        getting-started.md
      de/
        getting-started.md

Then filter by locale when querying:

---
import { getCollection } from 'astro:content';

const locale = 'fr';
const posts = await getCollection('blog', ({ id }) => {
  return id.startsWith(`${locale}/`);
});
---

For posts that haven't been translated, you can fall back to the English version:

---
import { getCollection } from 'astro:content';

const locale = 'fr';
const allPosts = await getCollection('blog');

const localizedPosts = allPosts
  .filter(post => post.id.startsWith(`${locale}/`))
  .map(post => post.slug.replace(`${locale}/`, ''));

const englishPosts = allPosts
  .filter(post => post.id.startsWith('en/'))
  .filter(post => !localizedPosts.includes(post.slug.replace('en/', '')));

const posts = [
  ...allPosts.filter(post => post.id.startsWith(`${locale}/`)),
  ...englishPosts,
];
---

This gives French users all translated French posts plus any English-only posts, sorted together. It's not perfect UX — users see mixed-language content — but it's better than a page with five posts when you have fifty in English.


Dynamic Locale Detection in SSR

If you're running Astro in SSR mode, you may want to detect the user's preferred locale from the Accept-Language header and redirect accordingly rather than relying solely on URL structure.

Astro exposes request headers through Astro.request:

---
// src/pages/index.astro
// Only used when prefixDefaultLocale is true and you want automatic detection
const acceptLanguage = Astro.request.headers.get('accept-language') ?? '';

function parsePreferredLocale(header: string, supported: string[]): string {
  const requested = header
    .split(',')
    .map(part => {
      const [locale, q = '1'] = part.trim().split(';q=');
      return { locale: locale.trim().split('-')[0], q: parseFloat(q) };
    })
    .sort((a, b) => b.q - a.q)
    .map(item => item.locale);

  return requested.find(locale => supported.includes(locale)) ?? 'en';
}

const supported = ['en', 'fr', 'de', 'ja'];
const preferred = parsePreferredLocale(acceptLanguage, supported);

if (preferred !== 'en') {
  return Astro.redirect(`/${preferred}/`);
}
---

A few caveats here. First, this detection only runs server-side — for SSG, the page is built once and served to everyone, so you can't tailor it per-user. Second, always set a Vary: Accept-Language header when doing server-side locale detection, so CDNs cache the correct version per language preference. Third, respect explicit URL choices over header detection — if a user navigates to /fr/ directly, don't redirect them based on their browser settings.

For most production sites, letting users explicitly choose their language via a locale switcher is more reliable and respects user intent.


Choosing a Workflow That Scales

For small sites (under 20 pages, 2-3 locales), the built-in Astro i18n routing plus a typed JSON utility function is enough. Don't add a library until you feel the specific pain that library solves.

For larger sites or teams with dedicated translators, you need a TMS. The key questions are:

  • How do translators access strings? Git-based workflows work well for technical teams. A UI-based platform is better for non-technical translators.
  • How do translations get into the build? Pull at build time (simpler, possible stale) or runtime CDN fetch (always fresh, requires SSR or client-side loading).
  • How do you handle translation quality? AI translation is fast, but glossary enforcement and review workflows matter for brand consistency.

Better i18n is built for scenarios where you want developer-grade tooling (type-safe SDKs, Git integration, CI/CD hooks) without forcing translators to use Git. Translations live in a managed platform, the SDK generates types from your schema at build time, and the CDN serves the latest approved strings. You can view the features page to see how this approach compares to alternatives on key dimensions.


Practical Summary

Here's what to take away:

Use Astro's built-in i18n routing for URL structure and locale detection. It's solid, well-integrated, and handles the cases that used to require custom middleware.

Write a typed translation utility for small-to-medium sites. It's less than 20 lines of TypeScript and gives you meaningful compile-time guarantees.

Add a community library (astro-i18next, Paraglide, intlayer) when you need pluralization, complex namespace management, or build-time type generation across a large key set.

Connect a TMS when your translation workflow involves non-technical contributors, multiple review stages, or more than a handful of locales. Pull translations at build time for SSG, or use runtime CDN delivery for SSR.

Don't skip SEO fundamentals: hreflang tags, canonical URLs, locale-specific metadata. These are easy to miss and hard to audit after the fact. Use Search Console alongside your analytics to monitor whether your localized pages are being indexed and served to the right audiences in each market.

The Astro i18n story is more coherent than it used to be. The built-in primitives cover the common cases, and the ecosystem fills the gaps. The main thing is to pick an approach that matches your site's actual complexity — and resist the temptation to over-engineer until you have a real problem to solve.


Better i18n is a developer-first localization platform built for modern frontend teams. Type-safe SDKs, Git-based workflows, CDN delivery, and AI translation with glossary enforcement — without locale files in your repo.