Aller au contenu
21 mai 20269 min readTutorials

Internationaliser un site Astro avec Locize

Astro est le framework web static-first pour les sites orientés contenu : routage basé sur les fichiers, zéro JavaScript par défaut, des « islands » pour l'interactivité opt-in, un mode SSR à la demande pour les parties qui en ont besoin. Depuis la v4.0 (fin 2023, stable en v5+, actuelle en v6.x), Astro embarque le routage i18n intégré : préfixage d'URL (/en/, /de/), le helper Astro.currentLocale, la détection de la langue du navigateur et les tags SEO <link rel="alternate" hreflang> automatiques.

Ce que l'i18n intégré d'Astro ne fournit pas, c'est une fonction de traduction (t()), la pluralisation ou l'interpolation de messages. La recette i18n officielle d'Astro montre le pattern canonique « construisez-le vous-même » : importez un fichier JSON par locale, écrivez cinq lignes de helper, terminé. C'est exactement là que Locize se branche : locize-cli télécharge le dernier JSON publié depuis Locize dans src/i18n/locales/{lng}/{ns}.json au moment du build, un petit module ui.ts les assemble en un arbre de lookup à plat, et le build statique d'Astro les récupère au compile time.

Cet article décrit l'intégration de bout en bout. Le code complet vit sur github.com/locize/locize-astro-example.

Si vous êtes sur Nuxt à la place, suivez le walkthrough Nuxt 4. Si vous êtes sur React Router v7 (mode Framework), voir le walkthrough React Router v7 : tous deux couvrent des intégrations plus riches (éditeur in-context, saveMissing à l'exécution) qui ne s'insèrent pas directement dans le modèle static-by-default d'Astro.

TL;DR

  • Configurez le routage i18n intégré d'Astro dans astro.config.mjs : liste des locales + prefixDefaultLocale: true pour des URLs uniformes.
  • Utilisez des routes de pages dynamiques [lang]/ avec getStaticPaths, pour que chaque locale soit pré-rendue statiquement.
  • Synchronisez les traductions dans l'app au moment du build via la commande download de locize-cli. Pas de saut CDN à l'exécution, pas de considération SSR.
  • Écrivez un helper useTranslations(lang) de cinq lignes dans src/i18n/utils.ts. Astro ne livre délibérément pas de t() ; c'est le pattern canonique de leur propre documentation.
  • Posez de nouvelles clés via npm run syncLocales, l'extraction statique de i18next-cli ou l'app web Locize, selon ce qui correspond à votre workflow. Aucun saveMissing à l'exécution n'est possible depuis la couche statique.
  • Besoin de saveMissing à l'exécution, de fetch CDN live ou de l'éditeur in-context ? Montez n'importe quelle island React/Vue/Svelte via @astrojs/<framework> et utilisez i18next-locize-backend + le package locize à l'intérieur de l'island.
  • Exemple complet fonctionnel : github.com/locize/locize-astro-example

Comment les pièces s'emboîtent

Astro est static-by-default. Les pages sont pré-rendues en HTML au moment du build et servies comme fichiers statiques (ou, en mode SSR, via un adaptateur à la demande). L'i18n intégré d'Astro est propriétaire de la couche routage : préfixage d'URL, redirections sensibles à la locale, tags hreflang, helpers de détection navigateur, mais reste délibérément en dehors de la couche traduction des messages. C'est pourquoi chaque tuto i18n Astro se termine par « maintenant écrivez votre propre helper t() » : la philosophie d'Astro est de vous donner des primitives, pas des opinions sur la traduction.

Locize est la couche de gestion des traductions. L'intégration est bundle-only à l'exécution :

  • Au build : locize-cli télécharge le dernier JSON publié depuis le CDN Locize dans src/i18n/locales/{lng}/{ns}.json. Les fichiers JSON committés sont importés directement par src/i18n/ui.ts au chargement du module et assemblés en un arbre de lookup à plat.
  • t() est du TypeScript local : cinq lignes qui gèrent le fallback de locale + l'interpolation au format {name}. Pas de bibliothèque runtime, pas de peer dep.
  • Les mises à jour nécessitent un rebuild : comme tout autre changement de contenu Astro. Lancez npm run downloadLocales et redéployez.

Ce dernier point est le compromis. Un aller-retour « traduction publiée dans Locize → utilisateurs finaux la voient » signifie relancer le build. Si vous préférez un fetch CDN live (publication → la vue de page suivante le récupère, sans redéploiement), vous montez une framework island et utilisez i18next-locize-backend à l'intérieur. Le walkthrough React Router v7 couvre cette forme, et vous pouvez adopter le même pattern par-island dans Astro. Nous y reviendrons à la fin.

C'est parti

Prérequis

Node.js 22+ (Astro 6 l'exige), npm/pnpm/yarn, et une familiarité de base avec Astro. L'exemple ci-dessous cible Astro 6.3.

Layout du projet

Générez une nouvelle app Astro, ou faites git clone de l'exemple :

npm create astro@latest my-app
cd my-app

La forme de l'arborescence pertinente après câblage de i18n + Locize :

my-app/
├── astro.config.mjsAstro's built-in i18n config
├── package.json                — locize-cli download/sync scripts
└── src/
    ├── pages/
    │   ├── index.astro         — root /, redirects to /en/
    │   └── [lang]/
    │       ├── index.astro     — home, renders for each locale via getStaticPaths
    │       └── second.astro    — secondary page, same pattern
    ├── layouts/
    │   └── Layout.astro        — shared <html>/<head>/<body>
    ├── components/
    │   └── LanguagePicker.astro — swaps the /{lang}/ prefix
    └── i18n/
        ├── ui.ts               — assembles namespaced JSON into a flat tree
        ├── utils.ts            — getLangFromUrl + useTranslations helpers
        └── locales/            — locize-cli download target (one JSON per ns)
            ├── en/
            │   ├── common.json
            │   ├── index.json
            │   └── second.json
            └── de/
                ├── common.json
                ├── index.json
                └── second.json

Installer les dépendances

npm install astro
npm install --save-dev locize-cli

C'est tout. Pas de bibliothèque runtime de traduction, pas de package d'intégration framework. L'i18n intégré d'Astro + locize-cli + ~50 lignes de helpers couvrent toute l'histoire.

Configurer l'i18n intégré d'Astro dans astro.config.mjs

import { defineConfig } from 'astro/config'

export default defineConfig({
  i18n: {
    defaultLocale: 'en',
    locales: ['en', 'de'],
    routing: {
      prefixDefaultLocale: true   // /en/, /de/ — uniform URLs
    }
  }
})

Astro prend désormais en charge :

  • Le routage des URLs pour chaque page sous src/pages/[lang]/ : /en/, /de/, /en/second, /de/second, etc.
  • Astro.currentLocale disponible dans chaque page/composant gratuitement.
  • La détection de la langue du navigateur via Astro.preferredLocale et Astro.preferredLocaleList (routes SSR/à la demande uniquement).
  • Les tags <link rel="alternate" hreflang> automatiques quand vous l'activez.

Avec prefixDefaultLocale: true, chaque locale reçoit un préfixe d'URL : uniforme, SEO-clean et facile à gérer avec des routes dynamiques [lang]/ + getStaticPaths. (Si vous préférez voir la locale par défaut servie à / sans préfixe, mettez-le à false et utilisez des pages basées sur les fichiers au lieu de [lang]/. L'exemple utilise la forme préfixée parce qu'elle passe mieux à l'échelle.)

Télécharger les traductions depuis Locize au build

Ajoutez à package.json :

{
  "scripts": {
    "downloadLocales": "locize download --project-id=<your-id> --ver=latest --cdn-type=standard --clean=true --path=./src/i18n/locales",
    "syncLocales":     "locize sync --project-id=<your-id> --ver=latest --cdn-type=standard --api-key=<your-write-key> --path=./src/i18n/locales --dry=true"
  }
}

Exécutez npm run downloadLocales avant npm run build (manuellement, en CI ou comme hook prebuild), pour que le JSON empaqueté soit toujours frais.

Standard vs Pro CDN. Locize propose deux infrastructures CDN (comparaison complète dans Types de CDN : Standard vs Pro) : Standard sur api.lite.locize.app, basé sur BunnyCDN, gratuit pour des volumes mensuels de téléchargement généreux, public uniquement (par défaut pour les nouveaux projets) ; Pro sur api.locize.app, basé sur CloudFront, payant, prend en charge les téléchargements privés + un contrôle de cache personnalisé. Faites correspondre --cdn-type sur le flag de la cli avec ce sur quoi est votre projet.

L'arbre des messages — src/i18n/ui.ts

Locize émet un JSON par namespace par langue. Astro n'a pas de notion de namespaces i18n, donc nous assemblons la disposition sur disque en un arbre de lookup à plat unique au chargement du module, avec le namespace inscrit en préfixe dans chaque clé :

import enCommon from './locales/en/common.json' with { type: 'json' }
import enIndex  from './locales/en/index.json'  with { type: 'json' }
import enSecond from './locales/en/second.json' with { type: 'json' }
import deCommon from './locales/de/common.json' with { type: 'json' }
import deIndex  from './locales/de/index.json'  with { type: 'json' }
import deSecond from './locales/de/second.json' with { type: 'json' }

type Messages = Record<string, string>

function prefix (ns: string, obj: Messages): Messages {
  return Object.fromEntries(
    Object.entries(obj).map(([k, v]) => [`${ns}.${k}`, v])
  )
}

export const ui = {
  en: { ...prefix('common', enCommon), ...prefix('index', enIndex), ...prefix('second', enSecond) },
  de: { ...prefix('common', deCommon), ...prefix('index', deIndex), ...prefix('second', deSecond) }
} as const

export const defaultLang = 'en'

export const languages = {
  en: 'English',
  de: 'Deutsch'
} as const

export type Lang = keyof typeof ui
export type TranslationKey = keyof typeof ui[typeof defaultLang]

La sortie ressemble à :

ui.en = {
  'common.headTitle': 'Locize + Astro',
  'common.skipToContent': 'Skip to content',
  'index.title': 'Hello, {name}!',
  'second.title': 'The second page',
  // ...
}

TranslationKey est dérivé de l'arbre de la locale par défaut, donc TypeScript autocomplète la clé dans les fichiers .astro et rejette les fautes de frappe au compile time.

Le helper t()src/i18n/utils.ts

import { ui, defaultLang, type Lang, type TranslationKey } from './ui'

export function getLangFromUrl (url: URL): Lang {
  const [, lang] = url.pathname.split('/')
  if (lang && lang in ui) return lang as Lang
  return defaultLang
}

export function useTranslations (lang: Lang) {
  return function t (
    key: TranslationKey,
    values?: Record<string, string | number>
  ): string {
    const raw: string = ui[lang][key] ?? ui[defaultLang][key] ?? key
    if (!values) return raw
    return raw.replace(/\{(\w+)\}/g, (_, k: string) =>
      values[k] !== undefined ? String(values[k]) : `{${k}}`
    )
  }
}

Cinq lignes de logique. Chaîne de fallback : la clé demandée dans la locale active, puis la locale par défaut, puis la chaîne brute de la clé. Interpolation optionnelle au format {name} qui passe les valeurs via le second argument. C'est toute l'API d'exécution.

Ce pattern est le même que celui que montre la recette i18n officielle d'Astro ; nous avons juste superposé la disposition sur disque par namespace de Locize par-dessus.

Utiliser t() dans les pages

Les routes dynamiques [lang]/ + getStaticPaths signifient qu'Astro pré-rend un fichier HTML par locale au moment du build :

---
// src/pages/[lang]/index.astro
import Layout from '../../layouts/Layout.astro'
import { ui, type Lang } from '../../i18n/ui'
import { useTranslations } from '../../i18n/utils'
import { getRelativeLocaleUrl } from 'astro:i18n'

export function getStaticPaths () {
  return Object.keys(ui).map(lang => ({ params: { lang } }))
}

const { lang } = Astro.params as { lang: Lang }
const t = useTranslations(lang)
---

<Layout lang={lang} title={t('common.headTitle')}>
  <h1>{t('index.title', { name: 'Astro' })}</h1>
  <p>{t('index.subtitle')}</p>
  <p>
    <a href={getRelativeLocaleUrl(lang, 'second/')}>
      {t('index.goto.second')}
    </a>
  </p>
</Layout>

La fonction getStaticPaths mappe chaque locale à un chemin statique ; lang arrive comme paramètre de route. Du pur travail au compile time : pas de runtime côté serveur, pas de considération d'hydratation.

Le sélecteur de langue

La recette i18n des docs d'Astro montre un sélecteur simple qui échange le préfixe de locale sur l'URL courante :

---
// src/components/LanguagePicker.astro
import { languages, type Lang } from '../i18n/ui'

interface Props {
  currentLang: Lang
}
const { currentLang } = Astro.props

function pathForLang (target: Lang): string {
  const { pathname } = Astro.url
  const parts = pathname.split('/')
  // "/en/foo/bar" → ["", "en", "foo", "bar"]
  parts[1] = target
  return parts.join('/')
}
---

<nav>
  <ul>
    {Object.entries(languages).map(([lang, label]) => (
      <li>
        {lang === currentLang ? (
          <strong>{label}</strong>
        ) : (
          <a href={pathForLang(lang as Lang)}>{label}</a>
        )}
      </li>
    ))}
  </ul>
</nav>

Déposez-le dans src/layouts/Layout.astro et il est disponible sur chaque page.

Trois façons d'amener de nouvelles clés dans Locize

Astro est static-by-default : les pages sont pré-rendues, donc un push saveMissing à l'exécution depuis des utilisateurs en production n'est pas possible sans un adaptateur SSR, et Astro décourage de toute façon les écritures depuis la couche statique. Il existe trois chemins viables à la place :

  1. npm run syncLocales (câblé dans package.json, retirez le flag --dry=true pour réellement pousser). locize-cli lit vos src/i18n/locales/{lng}/{ns}.json locaux et envoie toutes les clés pas encore présentes dans Locize. Manuel ou déclenché en CI, sans write-key dans le navigateur.
  2. Extraction statique via i18next-cli ou similaire : scanne vos sources .astro à la recherche d'appels t('…'), écrit les nouvelles clés dans le JSON local, puis synchronise comme ci-dessus.
  3. L'app web Locize : ajoutez des clés directement dans l'éditeur et récupérez-les avec downloadLocales avant le prochain build.

Choisissez ce qui correspond à votre workflow. Aucun n'exige de livrer une write API key dans votre artefact de build.

Ce que cette forme n'inclut intentionnellement pas

Trois choses que vous pourriez attendre des walkthroughs Nuxt ou React Router v7 sont délibérément absentes ici :

  • Pas de saveMissing à l'exécution. Voir ci-dessus : les builds statiques Astro ne peuvent pas pousser, et une app Astro SSR ferait cela depuis l'intérieur d'une island, pas depuis la couche statique.
  • Pas d'éditeur in-context sur les pages statiques. L'éditeur locize a besoin d'un DOM qui se re-rend quand l'éditeur met à jour une chaîne. La sortie statique d'Astro n'a pas ce hook.
  • Pas de fetch CDN live. Les traductions sont empaquetées au moment du build. Pour servir une traduction fraîche, lancez npm run downloadLocales et redéployez. Cela correspond au modèle static-first d'Astro et garde l'artefact de build autonome.

Tous ces trois points fonctionnent à l'intérieur de framework islands. Montez une island React, Vue, Svelte, Solid ou Preact via l'intégration @astrojs/<framework> correspondante, et à l'intérieur de cette island utilisez directement i18next-locize-backend + le package locize. La couche statique d'Astro gère le routage + l'habillage de la page ; les islands gèrent les parties qui ont besoin d'un runtime.

Quand ajouter une framework island

L'intégration static-only couvre la plupart des sites de contenu : blogs, pages marketing, docs, landing pages. Ajoutez une framework island quand vous avez besoin de :

  • Mises à jour live des traductions sans redéploiement. Utilisez i18next-locize-backend à l'intérieur de l'island pour fetcher les traductions depuis le CDN Locize à chaque visite (ou par route, si vous cachez).
  • Édition in-context pour les traducteurs. L'overlay ?incontext=true du package locize a besoin d'un runtime qui se re-rend sur changement de chaîne : disponible uniquement à l'intérieur d'une island hydratée.
  • saveMissing à l'exécution. Même contrainte : une island hydratée peut pousser de nouvelles clés ; le HTML statique ne le peut pas.

Le pattern ressemble à ceci (pour une island React) :

---
// src/pages/[lang]/interactive.astro
import Layout from '../../layouts/Layout.astro'
import I18nIsland from '../../components/I18nIsland.tsx'
---

<Layout lang={Astro.currentLocale}>
  <I18nIsland client:load lang={Astro.currentLocale} />
</Layout>
// src/components/I18nIsland.tsx
import i18next from 'i18next'
import Backend from 'i18next-locize-backend'
import { initReactI18next, useTranslation } from 'react-i18next'

i18next
  .use(Backend)
  .use(initReactI18next)
  .init({
    fallbackLng: 'en',
    saveMissing: import.meta.env.DEV,
    backend: {
      projectId: import.meta.env.PUBLIC_LOCIZE_PROJECT_ID,
      apiKey: import.meta.env.DEV ? import.meta.env.LOCIZE_API_KEY : undefined,
      version: 'latest'
    }
  })

export default function I18nIsland ({ lang }: { lang: string }) {
  const { t, i18n } = useTranslation()
  if (i18n.language !== lang) i18n.changeLanguage(lang)
  return <h2>{t('island.heading')}</h2>
}

client:load indique à Astro d'hydrater ce composant immédiatement au chargement de la page ; vous pouvez utiliser client:visible pour différer l'hydratation jusqu'à ce que l'island entre dans le viewport. La configuration exacte de i18next-locize-backend est documentée dans le walkthrough React Router v7 : le même câblage de backend fonctionne dans n'importe quel contexte React/Vue/Svelte/Solid, y compris à l'intérieur d'une island Astro.

🎉 Félicitations

Une configuration Astro 6 + Locize fonctionnelle vous offre :

  • i18n static-first sans flash de contenu non traduit (tout est pré-rendu par locale)
  • Préfixage d'URL SEO-clean via le routage i18n intégré d'Astro
  • Synchronisation des traductions au build via locize-cli, compatible serverless, sans saut CDN par requête
  • t() type-safe avec autocomplétion sur les clés de traduction
  • Une porte de sortie vers des islands dynamiques quand vous avez besoin de mises à jour à l'exécution ou d'édition in-context

🧑‍💻 Le code complet : github.com/locize/locize-astro-example.

Les fondateurs de Locize sont aussi les créateurs d'i18next. En utilisant Locize, vous soutenez directement l'avenir d'i18next.

Voir aussi