Skip to content

Add a language

Themes marked i18n-ready on their theme page are built so a second language is a configuration task, not a rewrite. English stays the complete source of truth in src/config/site.ts. Every other language is an override file that only holds the keys you translate. Anything you leave out falls back to English.

  • Every component reads its copy through getSiteConfig(Astro.url) in src/config/localized.ts, so the same component renders English on /about/ and French on /fr/about/.
  • Every route has a twin under src/pages/[locale]/. With only English registered those twins build zero pages. Register a language and they light up.
  • Internal links inside a localized page carry the language prefix. Config values whose key ends in url or href are prefixed automatically, and components use localizePath() for the links they build themselves.
  • <html lang>, og:locale and hreflang alternates follow the language of the page.

Copy the template and rename it to the language code you want:

src/i18n/locales/template.ts -> src/i18n/locales/fr.ts

The file exports one object that mirrors src/config/site.ts key for key. Every key is optional. Translate what you need:

import type { SiteConfig } from "~/config/site";
import type { DeepPartial } from "~/i18n/config";
const overrides: DeepPartial<SiteConfig> = {
navigation: {
links: [
{ label: "Accueil", url: "/" },
{ label: "Tarifs", url: "/pricing/" },
],
},
hero: {
heading: "Des factures claires, des clients sereins.",
},
};
export default overrides;

Keep internal URLs exactly as they are in English. The theme adds the /fr/ prefix for you.

Open src/i18n/config.ts and add the language to the locales map:

import fr from "./locales/fr";
export const locales = {
en: { label: "English", htmlLang: "en", ogLocale: "en_US", overrides: {} },
fr: { label: "Français", htmlLang: "fr", ogLocale: "fr_FR", overrides: fr },
} satisfies Record<string, LocaleDefinition>;

htmlLang goes into <html lang>, ogLocale into the Open Graph tags.

Terminal window
pnpm build

Every route now has a /fr/... twin. The page count roughly doubles. Open /fr/ and check the navigation, then open one detail page and check its links stay under /fr/.

Blog posts, case studies and other Markdown collections are shared across languages. The listing pages and detail routes exist under /fr/, but the Markdown body is whatever you wrote. Two common approaches:

  • Keep long-form content in one language and translate only the interface and the marketing pages. This is what most small teams ship first.
  • Duplicate the collection per language and point the localized listing at the right one. This is a small code change in the [locale] mirror of that route.

When you add a page under src/pages/, add its twin under src/pages/[locale]/. Copy the sibling that already exists there. Static pages are three lines:

---
import Page from "../about.astro";
import { getLocaleStaticPaths } from "~/i18n/config";
export const getStaticPaths = getLocaleStaticPaths;
---
<Page />

Dynamic routes ([slug], [...page]) map every entry to every registered language. The existing mirrors show the pattern for collections, config arrays and paginated lists.

Two themes with a slightly different layout

Section titled “Two themes with a slightly different layout”
  • Sable keeps the override objects inline in src/i18n/config.ts instead of separate files. Same shape, same rules.
  • Moxie keeps the English copy in src/i18n/locales/en.ts. src/config/site.ts is the runtime facade there. Create src/i18n/locales/fr.ts next to it.
  1. Every visible string you care about is in the override file. Grep the built /fr/ pages for English words to find leftovers.
  2. Links on the localized pages start with /fr/. Grep dist/fr for href="/ without the prefix.
  3. <html lang="fr"> is on the localized pages.
  4. The language switcher, if you add one, links with localizePath(Astro.url.pathname, code).