A missing translation
is a type error.

Translation files drift because nothing checks them — a key is renamed in English, the German catalog keeps the old one, and a visitor gets a raw key or an English string. Here the second catalog is typed against the first, so the gap fails your build instead of your user.

app.config.ts
TypeScript
// app.config.ts — the whole wiring
export default {
  locales: ['en', 'de'] as const,
  defaultLocale: 'en' as const,
}

// src/locales/de.ts — typed against en: a missing key fails typecheck
export default defineLocale<typeof en>()({
  'nav.pricing': 'Preise',
})

What you get from one field.

One config field wires the whole thing

Declare `locales` and `defaultLocale` in app.config.ts and the framework wraps your app in a provider, loads the matching catalog, and makes the translation hooks work app-wide. There is no provider to mount, no loader to write, and no bootstrap file to keep in sync.

Catalogs that typecheck against each other

The default locale defines the key set; every other catalog is typed against it. Add a key in English and the German file stops compiling until it has one too — which is the only moment anyone will actually add it.

The URL strategy follows the render mode

A statically rendered page cannot read a cookie at build time, so it gets URL-prefixed locales (/de/...); an interactive app can, so it gets a cookie and no URL noise. Both ship in the templates, chosen by what the page actually is rather than by a project-wide guess.

Resolution that respects the visitor

An explicit choice wins, then the stored preference, then Accept-Language, then your default. A visitor who picked German stays in German across a reload; one who never chose gets their browser's preference instead of your headquarters' language.

Translated metadata, not just body copy

Route metadata is computed with the active locale, so titles, descriptions, canonicals and OG tags are translated in the HTML a crawler reads — the half of i18n that a client-side library structurally cannot do.

Dates, numbers and plurals from the platform

Formatting rides Intl rather than a bundled locale database, so a date is formatted the way that locale actually writes dates, plural rules are the language's own, and nothing ships megabytes of CLDR to the browser.

Why the type system holds the catalogs.

Nothing else notices a missing key in time

A runtime fallback hides the gap: the page renders, the key resolves to English or to itself, and the bug reaches a user before it reaches a developer. A lint rule sees the file but not the intent. Typing the secondary catalogs against the primary moves the discovery to the only place where fixing it is trivial — the moment you added the key. It is the same reasoning the rest of the framework uses for schemas and procedures: put the contract where the compiler can hold it.

A missing translation, both ways.

A key that falls back at runtime
Discovered by a user
// en.json
{ "cart.items": "You have %d items" }
// de.json — somebody will add it later
{}

t('cart.itmes', { count: 3 })
//  ^ typo. No error. Renders the key, or the English, or nothing —
//    whichever your library calls graceful. A user finds it.
A key that fails the build
Discovered by tsc
// locales/en.ts
export const en = { 'cart.items': 'You have {count, plural, one {# item} other {# items}}' }

// locales/de.ts — a missing key is a TYPE ERROR, not a fallback
export const de = { 'cart.items': 'Du hast {count, plural, one {# Artikel} other {# Artikel}}' }

t('cart.items', { count: 3 })
//  ^ the key is checked, and so is `count` — a message whose
//    placeholders differ between languages does not compile.

A fallback is a design that ships English to a German visitor and calls it handled. Typed catalogs move that discovery to the build: a key one language has and the other does not is a type error, and so is a message whose placeholders differ between them.

Internationalization, in depth.

Why do translation files drift, and what stops it here?

Because nothing checks them. A key is renamed in English, the German catalog keeps the old one, and the gap surfaces as a raw key or an English string in front of a German visitor — usually reported by a customer rather than caught in review.

Here the default locale defines the key set and every other catalog is typed against it. Adding a key in English stops the German file compiling until it has one too, which is the only moment anyone will actually add it. A runtime fallback would hide exactly that moment.

It is the same principle the rest of the framework applies to schemas and procedures: put the contract where the compiler can hold it, so the failure happens at the keystroke rather than in production.

How much wiring do I have to do?

Declare `locales` and `defaultLocale` in the app config. The framework wraps the app in a provider, loads the matching catalog and makes the translation hooks work app-wide — there is no provider to mount, no loader to write and no bootstrap file to keep in step.

Growing to another language is a code in that list plus a catalog file. Because the catalog is typed against the default, the compiler then tells you exactly what is missing rather than leaving you to diff two objects by eye.

Every frontend template ships bilingual from the start, so the wiring is demonstrated rather than described — including the locale switcher and the strategy that fits the page's render mode.

Why does the URL strategy depend on the render mode?

Because a statically rendered page cannot read a cookie at build time. It is generated once per locale, so the locale has to be in the URL — `/de/...` — for the right variant to exist as a file and be indexable.

An interactive page can read a cookie, so it does not need the URL noise: an authenticated dashboard keeps one clean path and switches language in place. Forcing one strategy on both means either unindexable static pages or URLs cluttered where they need not be.

Resolution respects the visitor in a defined order: an explicit choice wins, then the stored preference, then Accept-Language, then your default. Someone who picked German stays in German across a reload; someone who never chose gets their browser's preference rather than your headquarters' language.

What about the parts search engines read?

Route metadata is computed with the active locale, so titles, descriptions, canonicals, OG tags and structured data are translated in the HTML itself — the version a crawler indexes. A client-side translation library structurally cannot do that half, because the crawler has already read the page.

The structured data must also declare the right language, which is easy to get wrong: a hardcoded value means every translated page tells search and answer engines it is English. This site had exactly that defect until it was found by reading the rendered German HTML rather than the code.

Formatting rides the platform's Intl rather than a bundled locale database, so dates read the way that locale writes dates, plural rules are the language's own, and nothing ships megabytes of locale data to the browser.

What the config field wires.

What one config field wires up
ConcernHow it is handled
ProviderWrapped automatically from the declared locales — nothing to mount, no bootstrap file.
CatalogsTyped against the default locale, so a missing key fails the build rather than the visitor.
URL strategyURL-prefixed where the page is statically rendered, cookie-based where it is interactive.
ResolutionExplicit choice, then stored preference, then Accept-Language, then your default.
MetadataTitles, canonicals, OG tags and structured data computed per route with the active locale.
FormattingDates, numbers and plurals through Intl — no bundled CLDR shipped to the browser.

Frequently asked questions

What happens if a translation is missing?

The build fails. Catalogs are typed against the default locale, so a missing key is a compile error rather than a runtime fallback — which is the point, since a fallback hides the gap until a user sees it.

Should I use URL prefixes or a cookie?

It follows the render mode rather than taste. A statically rendered page cannot read a cookie at build time, so it needs `/de/...` to exist as an indexable file; an interactive page can, so it keeps a clean URL. The templates ship both, chosen per page.

Are translated pages indexable?

Yes — metadata is computed per route with the active locale, so head tags, canonicals and structured data are translated in the HTML a crawler reads. Worth verifying in the rendered output, not just the code: a hardcoded language value in structured data is an easy defect to ship.

How large is the client bundle?

Formatting uses the platform's Intl rather than a bundled locale database, so plural rules and date formats cost nothing on the wire. What you ship is your own catalog for the active locale.

Can I add a third language later?

Add the code to the config list and a catalog file. Because catalogs are typed against the default, the compiler then enumerates exactly what is missing instead of leaving you to compare two objects by eye.

What i18n touches.

Open the framework. See it for yourself.

Every primitive on this page is in the framework today. Clone the starter, run `voltro dev`, and have it on screen in two minutes.