EN FR

Chapter 11Build

Multilingual

One URL per language, built at build time. A fallback that keeps the page usable while it is being translated, and a check that refuses to let the remaining work slip through.

4 min read9 sectionsChapter 11 / 22

One URL per language

text text
/            →  build-time redirect to /fr/
/fr/            French home page
/fr/services/
/en/            English home page
/en/services/

The prefix is present even for the reference language (prefixDefaultLocale: true). Without it the same page would be served at two addresses — the root and the prefix — and a canonical would have to be arbitrated between two identical URLs.

No JavaScript language switching

Neither on the site nor in the overlay, where the language choice is made of real links. A script-based switch would produce one URL for two contents: invisible to engines, impossible to share.

The files

text text
src/content/pages/
├── fr/            the reference language — it makes the list of pages
│   ├── home.json
│   └── services.json
└── en/            the translation
    ├── home.json
    └── services.json

The key structure must be identical across languages. That is not a convention: it is checked, in both directions, by check-locales.mjs.

When a translation is missing

Two mechanisms that seem to contradict each other and in fact complete each other:

On display

The page stays usable

A field missing from the translation is taken from the reference language rather than leaving a hole. The field is marked (data-cms-untranslated), and the overlay bar shows “2 texts still to translate on this page”.

On check

Nothing slips through silently

npm run check fails on any key present in one language and absent from another — in both directions, including a translated key that does not exist in the reference and would therefore never be displayed.

So the fallback is not a tolerance: it is what makes a page usable while it is being translated, without hiding the remaining work.

src/lib/locales.ts ts
export { localePath, mergeWithDefault, type MergeResult } from 'inline-core/translate';

export const LOCALES = ['fr', 'en'] as const;
export const DEFAULT_LOCALE = 'fr';

/** The language name in the language itself — never a code on screen. */
export const LOCALE_LABELS = {
  fr: 'Français',
  en: 'English',
};

The mechanism of the fallback lives in the package; the list of languages lives in the site. A shared package cannot know how many languages a given site has, nor what they are called.

Lists and translations

Items are reconciled by id, never by position. So a translation can order its testimonials differently without texts sliding from one item to another.

The corollary

An item added in one language must get the same id in the others. That is what the overlay does when the client adds a testimonial then switches language; it has to be done by hand when writing the JSON directly.

Adding a language

Four places, and all four are needed:

  1. The integration and i18n routing

    astro.config.mjs js
    integrations: [
      inline({ locales: ['fr', 'en', 'de'] }),   // reference language first
    ],
    
    i18n: {
      locales: ['fr', 'en', 'de'],
      defaultLocale: 'fr',
      routing: { prefixDefaultLocale: true },
    },

    The code must be two lowercase letters: the integration refuses anything else.

  2. The site languages

    src/lib/locales.ts ts
    export const LOCALES = ['fr', 'en', 'de'] as const;
    
    export const LOCALE_LABELS = {
      fr: 'Français',
      en: 'English',
      de: 'Deutsch',
    };

    The label is the language name in that language: the client sees “Deutsch”, never “de”.

  3. The parity check

    scripts/check-locales.mjs js
    const LOCALES = ['fr', 'en', 'de'];
  4. The HTML check

    scripts/check-html.mjs lists the built pages explicitly, one entry per page and per language. This is the most frequent omission: the check passes, but it verifies nothing for the new language.

    scripts/check-html.mjs js
    const PAGES = [
      { content: 'src/content/pages/fr/home.json', html: 'dist/fr/index.html' },
      { content: 'src/content/pages/en/home.json', html: 'dist/en/index.html' },
      { content: 'src/content/pages/de/home.json', html: 'dist/de/index.html' },
    ];

Then create src/content/pages/de/. While the files are missing, pages display in the reference language and npm run check reports it.

On the server side

Routes receive the site languages (createSaveRoute({ locales: LOCALES })) and only accept writes into a declared language folder. Without that, src/content/pages/zz/home.json would create a folder nothing builds, and the repository would fill with invisible content.

Each page declares itself in addition to its translations: that is the reciprocity engines expect — a page that does not cite itself is ignored by the group.

in the <head> html
<link rel="canonical" href="https://the-site.com/en/services/" />
<link rel="alternate" hreflang="fr" href="https://the-site.com/fr/services/" />
<link rel="alternate" hreflang="en" href="https://the-site.com/en/services/" />
<link rel="alternate" hreflang="x-default" href="https://the-site.com/fr/services/" />

x-default points at the reference language. All of it is produced by the layout from the language list — there is nothing to maintain by hand.

Editing in another language

In the overlay, the language selector is a set of real links to the same page in the other languages, read from data-cms-locales, placed at build time. The client switches language like a visitor, and their local draft stays attached to the page they were editing.

The bar shows how many fields on the current page are still taken from the reference language. That is the interface's only translation indicator — there is no progress table, no overview, no tree.

A monolingual site

Declare a single language everywhere. Nothing else changes: the fallback has nothing to do, the parity check passes trivially, the language selector does not appear. Moving to two languages later needs only the four changes above.

Checks

  • npm run check passes: keys are identical in every language.
  • Every page exists in the reference language — otherwise it is not built at all.
  • hreflang links are reciprocal and include x-default.
  • List item ids are the same across languages.
  • check-html.mjs has one entry per page and per language.
  • Language labels are written in their own language.