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 acmeWhat 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/aboutThe 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 locallyThe 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 mirrorEach 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
- Add the code to
SUPPORTED_LOCALESinsrc/lib/locale.tsAND tolocalesinapp.config.ts. - Add
src/locales/<code>.ts(mirroren.ts; the type-check enforces parity), and add it toCATALOGSinsrc/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 torenderMode: '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. Createsrc/pages/foo.tsxAND a one-line mirrorsrc/pages/[locale]/foo.tsx. Without the mirror, only the default-locale URL is emitted —/de/foo404s. - Exporting
metaas a plain object. The body localises, but the<title>/<meta description>stay English on/de. Exportmetaas a function of{ locale }and read the catalog viagetCatalog(locale). - Skipping
defineLocale<typeof en>()for a non-base catalog. PlaindefineCatalog(...)compiles but loses the parity check — the one feature that stops translations drifting. Always run non-base locales throughdefineLocale<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 fordocument.cookiere-introduces the hydration flash the framework is designed to avoid.