Internationalization

Explainer supports multiple languages across all three applications — docs, blog, and website — each with an approach tailored to its needs.

Shared behavior

Regardless of the application, the locale is persisted via a cookie (locale) scoped to the root domain. This means a language switch on one subdomain (e.g. blog.example.com) is automatically picked up by the others (e.g. docs.example.com).

Detection order (on first visit):

  1. locale cookie (set when the user explicitly switches)
  2. Browser language (navigator.language)
  3. Default locale (en)

Docs

The docs app uses locale-based URL routing. The locale appears as the first segment in the URL:

/{locale}/{project}/{path}
URLLocalePage
/en/explainer/getting-startedEnglishGetting Started
/fr/explainer/getting-startedFrenchGetting Started

Adding a locale

Create the locale directory

Inside your project’s version directory, create a new directory with the locale code:

mkdir -p apps/docs/src/content/docs/explainer/default/fr

Add translated content

Create MDX files that mirror the English structure:

explainer/default/fr/getting-started.mdx
---
title: Premiers pas
description: Configurez Explainer v2 et commencez à créer votre site de documentation.
order: 1
---

# Premiers pas

Bienvenue dans la documentation d'Explainer v2.

Verify

Navigate to /fr/explainer/getting-started. The locale switcher in the navbar allows users to switch between available languages.

File structure

explainer/
  default/
    en/                         # English (primary)
      getting-started.mdx
      guides/
        installation.mdx
    fr/                         # French
      getting-started.mdx
      guides/
        installation.mdx

Fallback behavior

There is no automatic fallback to English. Each locale must have its own content files. This ensures translations are intentional and complete.

Blog

The blog app uses locale-based URL routing combined with a translation dictionary for UI strings.

URL structure

/{locale}                       # Article listing
/{locale}/{slug}                # Article page
/{locale}/rss.xml               # RSS feed

Content files live in locale-prefixed directories under src/content/posts/:

src/content/posts/
  en/
    introducing-explainer-v2.mdx
  fr/
    introducing-explainer-v2.mdx

UI translations

All static UI strings (navigation labels, headings, placeholders, aria-labels) are stored in a translation dictionary at src/i18n/ui.ts and accessed via a useTranslations() helper:

src/i18n/utils.ts
import { ui, defaultLang, type UiKey } from './ui'

export function useTranslations(locale: string) {
  return function t(key: UiKey): string {
    return ui[locale as keyof typeof ui]?.[key] ?? ui[defaultLang][key]
  }
}

In .astro files, the locale comes from the URL parameter:

---
const { locale } = Astro.props
const t = useTranslations(locale)
---

<h1>{t('index.heading')}</h1>

In .tsx components, the locale is passed as a prop:

export function TagFilter({ tags, locale = 'en' }: TagFilterProps) {
  const t = useTranslations(locale)
  return <input placeholder={t('tagFilter.placeholder')} />
}

Redirect

The root / detects the user’s preferred locale (cookie → browser → default) and redirects to /{locale}.

Website

The website is a single-page landing page — there is no URL-based routing for locales. Instead, it uses a client-side translation swap approach.

How it works

  1. The page is statically generated with English content as the default.
  2. Each translatable element carries a data-t attribute referencing a translation key:
<h1 data-t="hero.title">Explain your ideas</h1>
  1. An inline script detects the locale (cookie → browser → default) and replaces text content with the matching translation:
document.querySelectorAll('[data-t]').forEach(function (el) {
  const key = el.getAttribute('data-t')
  if (!el.dataset.tDefault) el.dataset.tDefault = el.textContent
  if (translations[key]) el.textContent = translations[key]
})

Translation dictionary

Translations live in src/i18n/ui.ts, identical in structure to the blog:

src/i18n/ui.ts
export const ui = {
  en: {
    'hero.title': 'Explain your ideas',
    'cta.action': 'Read the docs',
    // ...
  },
  fr: {
    'hero.title': 'Expliquez vos idées',
    'cta.action': 'Lire la documentation',
    // ...
  },
}

The French translations are embedded in the page at build time via Astro’s define:vars and passed to the inline script.

Locale switcher

The navbar’s LocaleSwitcher sets the locale cookie and reloads the page (via onLocaleChange) instead of navigating to a different URL. On reload, the script picks up the new cookie and applies the correct translations.

Locale switcher

The LocaleSwitcher component in the navbar is shared across all apps. It:

  • Displays the current locale with a dropdown to switch
  • Sets a locale cookie on the root domain (e.g. .example.com) so the choice persists across subdomains
  • Supports two modes:
    • URL navigation (docs, blog) — follows switchUrls links to locale-prefixed routes
    • In-place reload (website) — uses the onLocaleChange callback to reload the page after setting the cookie