i18n

A bilingual (en/de) static site using the URL-prefix i18n strategy — type-checked translation catalogs, [locale] mirror pages, and per-locale SSG so each language is its own crawlable URL with a translated title. No backend.

A bilingual static site built the URL-prefix way: the default locale (English) lives at the bare path (/, /about), and every other locale is prefixed (/de, /de/about). Each translated page is its own crawlable URL with its own pre-rendered <title> — the SEO-friendly shape for docs, marketing, and any content where the URL is the product. Translations are type-checked against a base catalog so they can never silently drift, and voltro build emits one HTML file per (page × locale). No api, no rpc client, no SSR. Template id: frontend-i18n.

Scaffold

voltro create-project acme --web=frontend-i18n
voltro add-app site --template=frontend-i18n --to acme

What ships

apps/acme/web/                          # dir named by the app, not the template
├── app.config.ts                       # type:web, theme:'system', locales:['en','de']
├── package.json                        # + @voltro/i18n
├── tsconfig.json
├── README.md
└── src/
    ├── globals.css
    ├── globals.d.ts                     # `declare module '*.css'`
    ├── lib/
    │   └── locale.ts                    # SUPPORTED_LOCALES + URL-prefix helpers (single source of truth)
    ├── locales/
    │   ├── en.ts                        # base catalog (source of truth)
    │   └── de.ts                        # defineLocale<typeof en>() → parity-enforced
    └── pages/
        ├── layout.tsx                   # inner URL-driven <I18nProvider> + language switch
        ├── index.tsx                    # home — renderMode 'static', meta as fn of {locale}
        ├── about.tsx                    # second page
        └── [locale]/
            ├── index.tsx                # mirror → emits /de
            └── about.tsx                # mirror → emits /de/about

The app depends on @voltro/web / @voltro/client / @voltro/env / @voltro/cli + React 19 + @voltro/i18n — the only addition over the other web templates. No apis: entry; every page is renderMode: 'static'.

The catalogs — type-checked parity

src/locales/en.ts is the base catalog and the source of truth. src/locales/de.ts runs through defineLocale<typeof en>(), a curried helper that enforces exact key parity at the type level — drop or add a key and tsc --noEmit fails. That's the whole point of the base-catalog pattern: translations can't silently fall out of sync.

// src/locales/en.ts — base catalog
import { defineCatalog } from '@voltro/i18n'

export default defineCatalog({
  'nav.home':      'Home',
  'home.title':    'Hello, {name}',
  'home.tagline':  'A bilingual static site built with Voltro.',
  // …
} as const)
// src/locales/de.ts — MUST mirror en key-for-key, or tsc fails
import { defineLocale } from '@voltro/i18n'
import en from './en'

export default defineLocale<typeof en>()({
  'nav.home':      'Start',
  'home.title':    'Hallo, {name}',
  'home.tagline':  'Eine zweisprachige statische Website, gebaut mit Voltro.',
  // …
})

Keep ICU MessageFormat placeholders ({name}, {count, plural, …}) identical across locales — react-intl validates them at render time. Read them in components with <T id="…" /> (JSX) or useT('…') (imperative).

URL-prefix routing — lib/locale.ts + [locale] mirrors

The framework's auto-wired <I18nProvider> is cookie-driven (Strategy A — product dashboards). URL-prefix routing (Strategy B) needs the active locale to come from the URL instead, so src/lib/locale.ts is the single source of truth: it lists the supported locales (kept in sync with app.config.ts) and the prefix helpers.

// src/lib/locale.ts (excerpt)
import { useLocation } from '@voltro/web'

export const SUPPORTED_LOCALES = ['en', 'de'] as const
export const DEFAULT_LOCALE = 'en'

// '/de/about' → 'de'; '/about' → 'en' (default lives at the bare path)
export const useUrlLocale = (): Locale => localeFromPathname(useLocation())

// '/about' + 'de' → '/de/about'; the default locale stays bare ('/about')
export const withLocalePrefix = (path: string, locale: string): string =>
  locale === DEFAULT_LOCALE ? path : path === '/' ? `/${locale}` : `/${locale}${path}`

Each page has a one-line mirror under src/pages/[locale]/. The mirror re-exports the bare page verbatim (the component reads its locale from the URL via the layout's provider) and uses getStaticPaths to enumerate the non-default locales — which is what tells voltro build to emit the /de/... HTML. Without the mirror, only the default-locale URL is built.

// src/pages/[locale]/index.tsx — the entire file
import { SUPPORTED_LOCALES, DEFAULT_LOCALE } from '../../lib/locale'

export { default } from '../index'
export { renderMode, meta } from '../index'

export const getStaticPaths = async (): Promise<Array<{ params: { locale: string } }>> =>
  SUPPORTED_LOCALES
    .filter((l) => l !== DEFAULT_LOCALE)
    .map((locale) => ({ params: { locale } }))

Per-locale <title>meta as a function of { locale }

The body of each page is localised automatically (the SSG pipeline wraps every variant in the right catalog's provider). The document head is NOT — unless you export meta as a function of { locale }. The framework drives locale from params.locale on the [locale] route (and defaultLocale on the bare path), so each variant bakes the right <title> / <meta description> into its HTML — exactly what you want for SEO and social cards. A plain meta object would leave the head English on /de.

// src/pages/index.tsx
import { T, useT, useLocale } from '@voltro/i18n'
import { getCatalog } from '../lib/locale'

export const renderMode = 'static' as const

export const meta = ({ locale }: { locale: string }) => {
  const c = getCatalog(locale)               // the catalog for this locale, at build time
  return { title: c['meta.home.title'], description: c['meta.home.description'] }
}

export default function Index() {
  const greeting = useT('home.title', { name: 'Voltro' })   // imperative form
  return (
    <article>
      <h1>{greeting}</h1>
      <p><T id="home.tagline" /></p>                          {/* JSX form */}
      <p><T id="home.activeLocale" values={{ locale: useLocale().toUpperCase() }} /></p>
    </article>
  )
}

The layout — inner URL-driven provider + language switch

src/pages/layout.tsx wraps every page in an inner <I18nProvider> whose locale comes from useUrlLocale(). Because useLocation re-renders on client-side navigation, moving between /about and /de/about swaps the catalog without a full reload; at build time it resolves to the same locale the SSG pipeline pre-rendered. The language switch is a set of plain <a> links — one per locale, pointing at the current path under each prefix.

// src/pages/layout.tsx (excerpt)
import { I18nProvider, T } from '@voltro/i18n'
import { useLocation } from '@voltro/web'
import { CATALOGS, DEFAULT_LOCALE, SUPPORTED_LOCALES, stripLocalePrefix, useUrlLocale, withLocalePrefix } from '../lib/locale'

export default function Layout({ children }) {
  const locale = useUrlLocale()
  const barePath = stripLocalePrefix(useLocation())   // keep the page when switching language
  return (
    <I18nProvider locale={locale} messages={CATALOGS[locale]} defaultLocale={DEFAULT_LOCALE}>
      <nav>
        {SUPPORTED_LOCALES.map((code) => (
          <a key={code} href={withLocalePrefix(barePath, code)} aria-current={code === locale ? 'true' : undefined}>
            {code.toUpperCase()}
          </a>
        ))}
      </nav>
      <main>{children}</main>
    </I18nProvider>
  )
}

The nav uses plain <a> (not <Link>) on purpose: the hrefs are computed per locale, and <Link to> wants a statically-known route URL. The Router's global click interceptor SPA-navigates internal <a> anyway, so the inner provider still swaps catalogs without a reload.

Config — locales + defaultLocale

locales in app.config.ts is load-bearing: it tells voltro build to pre-render a per-locale variant for every [locale]/... mirror, each wrapped in the right catalog's provider. Every code listed MUST have a matching src/locales/<code>.ts, and defaultLocale MUST be one of locales. Keep these two fields in sync with SUPPORTED_LOCALES / DEFAULT_LOCALE in src/lib/locale.ts.

// app.config.ts
export default {
  type: 'web' as const,
  name: 'AcmeSite',
  port: 5173,
  theme: 'system' as const,
  locales:       ['en', 'de'] as const,
  defaultLocale: 'en'         as const,
  env,
}

Build + serve

voltro build .     # pre-render → one HTML file per (page × locale)
voltro start .     # serve the built output locally

The build emits the URL-prefixed tree:

dist/index.html            ← en (default, bare path)
dist/about/index.html      ← en
dist/de/index.html         ← de mirror
dist/de/about/index.html   ← de mirror

Each variant ships its localised body AND the right per-locale <title> / <meta description>. The output is pure static files — push it to any CDN.

Add a locale

  1. Add the code to SUPPORTED_LOCALES in src/lib/locale.ts AND to locales in app.config.ts.
  2. Add src/locales/<code>.ts (mirror en.ts; the type-check enforces parity), and add it to CATALOGS in src/lib/locale.ts.

The [locale] mirrors pick it up automatically — getStaticPaths reads SUPPORTED_LOCALES.

When to use frontend-i18n vs. the other web templates

You want… Pick
A multilingual content/marketing site where each language is its own crawlable URL frontend-i18n
A single-language content site pre-rendered to flat HTML frontend-static-blog
A documentation site frontend-docs
A blank React shell to bring your own brand frontend-blank
A product app where all locales share one URL (cookie-driven) any web template + locales in app.config.ts (Strategy A)

Pairs well with

  • Nothing on the backend — this is a self-contained static site. Add an api later (and an apis: entry) only when a page needs live, request-time data, then flip that page to renderMode: 'ssr' | 'isr'.
  • The cookie-only i18n strategy (Strategy A) for sibling product apps where the URL shouldn't encode the language — same catalogs, no [locale] mirrors. See URL strategies for both.

Anti-patterns

  • Forgetting the [locale] mirror for a new page. Create src/pages/foo.tsx AND a one-line mirror src/pages/[locale]/foo.tsx. Without the mirror, only the default-locale URL is emitted — /de/foo 404s.
  • Exporting meta as a plain object. The body localises, but the <title> / <meta description> stay English on /de. Export meta as a function of { locale } and read the catalog via getCatalog(locale).
  • Skipping defineLocale<typeof en>() for a non-base catalog. Plain defineCatalog(...) compiles but loses the parity check — the one feature that stops translations drifting. Always run non-base locales through defineLocale<typeof en>().
  • Angle brackets in catalog strings. react-intl's ICU parser treats <tag> as a rich-text element and fails (FORMAT_ERROR) if there's no matching close tag. Write "page titles", not "<title> tags", in a message.
  • Reading the cookie directly in a component. Use useT / useLocale — the active locale is React state inside the provider, not browser state. Reaching for document.cookie re-introduces the hydration flash the framework is designed to avoid.