# Important defaults

The defaults in Comvi i18n that surprise people: what each one does, why it is the default, and the one-line change when you want the other behaviour.

Canonical: https://comvi.io/docs/i18n/important-defaults

The i18n instance renders something on every call and stays quiet in production. Those two
choices explain most of the defaults here, and each comes with the one-line change for the other
behaviour.

## ICU is off until you name the compiler

The default compiler handles text and `{param}` placeholders. `{count, plural, …}` is not part
of it, and no constructor turns it on for you, `createNextI18n` included.

`src/i18n.ts`

```ts

export const i18n = createI18n({ locale: 'en', compiler: icuCompiler });
```

Without `compiler: icuCompiler`, an ICU message (`{count, plural, …}`) fails in development at
ingestion, before anything renders. Inline, it throws `E_ICU_SYNTAX` at the call that carries the
catalog; over a loader it fails that namespace's load instead: `E_ICU_SYNTAX` reaches
`onLoadError`, the namespace stays empty, and when that was the only namespace `init()` rejects
with `[@comvi/core] Failed to load all namespaces for locale "…": …` (`E_ALL_NAMESPACES_FAILED` in
production) rather than with `E_ICU_SYNTAX`. In production it
renders the message text literally and reports the error through `onError` (`console.error` when
no handler is set).
The compiler is a property of the catalog, not of where the catalog comes from: an instance that
loads from the Comvi CDN needs `compiler: icuCompiler` for the same reason an inline one does,
because some message in it says `plural`. ICU is not the default because the instance cannot
know whether your messages use the grammar, and a stray brace in a plain message would then be
parsed as ICU instead of rendered.

## A missing key renders as the key, and says nothing

Call `t('checkout.title')` when no catalog has that key and the heading reads
`checkout.title`. Nothing throws. Nothing is logged, in development or in production, unless
you ask for it.

`src/i18n.ts`

```ts

export const i18n = createI18n({
locale: 'en',
strict: 'dev', // logs "[@comvi/core] Translation not found" per miss in development
onMissingKey: ({ key }) => (import.meta.env.DEV ? `[MISSING: ${key}]` : ''),
});
```

The lookup walks the current locale, then the `fallbackLocale` chain, then emits `missingKey`,
then runs `onMissingKey`, and returns whatever that produced or the key itself. `strict: 'dev'`
adds a console warning per miss; `onMissingKey` replaces the text. The default is silent because
`t()` runs inside render, and a render that throws or spams the console over one untranslated
label takes the page down with it. The key string is at least readable, and it tells you which
key to add. Teams that want misses to be loud in tests set `strict: 'dev'` on the test instance and
leave the production one as it is.

## A missing parameter renders as its own placeholder

`t('greeting')` against `"Hello, {name}!"` renders `Hello, {name}!`. In development the instance
warns once per template and parameter; in production it says nothing.

`src/i18n.ts`

```ts

export const i18n = createI18n({ locale: 'en', missingParam: 'drop' }); // "Hello, !"
```

Leaving the placeholder visible is the default because it is the version you notice. A greeting
that reads `Hello, !` passes a glance; `Hello, {name}!` does not. An explicit `null` always
renders as an empty string, whichever mode is set, so a parameter you mean to blank out has a
way to say so.

## `fallbackLocale` is loaded with the active locale

`fallbackLocale: 'en'` on an instance with `locale: 'de'` means: for each key, look in `de`, and if
it is not there, or is empty there, look in `en`. With a loader composed, the `en` catalog is
fetched too, so the lookup has something to find.

`src/i18n.ts`

```ts

export const i18n = createI18n({ locale: 'de', fallbackLocale: 'en' }).use(
cdnLoader({ cdnUrl: 'https://cdn.comvi.io/<project-token>' }),
);

await i18n.init(); // fetches de.json and en.json, in parallel
await i18n.setLocale('fr'); // fetches fr.json; en.json is already in the cache
```

`init()` requests the active namespaces for the active locale and for each locale in the chain,
in parallel, and a namespace activated later is loaded the same way. The cost is one extra
request per fallback locale per namespace on the first load, and none for a catalog that is
already in the cache: inline `messages`, a payload hydrated from the server, or a fallback equal
to the locale. `setLocale()` fetches the target locale alone, since the chain's catalogs are
already there. A fallback catalog is a safety net, not a requirement: when it fails to load, the
failure reaches `onLoadError` and `onError` and `init()` still resolves; only the active locale's
own catalogs decide whether `init()` rejects. A fallback catalog that does not exist is requested
once, when its namespace is first loaded, reported through `onLoadError`, and not requested again
by a locale switch. A chain changed with `setFallbackLocale()` after `init()` is fetched by
`reloadMessages()` or by the next namespace activation.

An empty string in a catalog is not a translation. `t()`, `hasTranslation()` and `<T>` read it as
absent and move on to the next locale in the chain, and a key that is empty in every locale
behaves exactly like a missing one: `missingKey` fires and the key renders. Only the stored value
is judged. A message with text that renders empty for one call, an empty plural branch or an
empty parameter, is returned as it is.

## `t()` never waits for `init()`

`t()` is synchronous and reads whatever is in the cache at that moment. An inline catalog is in
the cache from the constructor, so `t()` works before `init()`. A catalog a loader has to fetch
is not there yet, so the same call returns the key until the fetch lands.

`src/main.tsx`

```tsx

const i18n = createI18n({ locale: 'en' }).use(cdnLoader({ cdnUrl: 'https://cdn.comvi.io/<project-token>' }));

createRoot(document.getElementById('root')!).render(
<I18nProvider i18n={i18n} fallback={<div className="spinner" />}>

</I18nProvider>,
);
```

The bindings call `init()` for you and expose the wait: a `fallback` prop in React and Solid,
an `isInitializing` flag from `useI18n()` in Vue and Svelte. Without a gate, the first paint of
a CDN-loaded app shows raw keys for one network round trip and then re-renders. That is the
trade: a synchronous `t()` is what lets you call it anywhere in a render, in an attribute or a
computed property, with no `await` and no Suspense boundary. Next.js and Nuxt load the catalog
on the server before the first byte is sent, so there the gate is never visible.

## The CDN loader fetches one file per locale and namespace

`cdnLoader({ cdnUrl })` requests `{cdnUrl}/{locale}.json` for the default namespace and
`{cdnUrl}/{namespace}/{locale}.json` for any other. One request per `locale:namespace` pair, no
retry, and a `fallback` import map behind it for when the network is not there.

`src/i18n.ts`

```ts

export const i18n = createI18n({ locale: 'en', fallbackLocale: 'en' }).use(
cdnLoader({
    cdnUrl: 'https://cdn.comvi.io/<project-token>',
    fallback: { en: () => import('./locales/en.json') }, // used when en.json fails to fetch
}),
);
```

When the fetch fails or returns something that is not a catalog, the loader tries the
`fallback` entry for `locale:namespace`, then the bare `locale` entry when the namespace is the
default one, and merges whatever that import returns. A fallback that works reports through
`onLoadSuccess`; only a pair with no fallback, or a fallback that also fails, reaches
`onLoadError`. When every namespace `init()` asked for fails and no fallback covers it, the
failure is reported through `onError` and then `init()` rejects with `E_ALL_NAMESPACES_FAILED`;
when at least one namespace landed, `init()` resolves and the misses are reported only. The loader
does not retry because the instance's per-key fallback chain already sits in front of it, and a retry
loop on a flapping CDN would multiply what every locale switch costs. Content Deploy writes the
files in exactly this layout, which is why the loader needs the endpoint URL and nothing else.

## Formatters follow the locale you are rendering in

`formatNumber`, `formatDate`, `formatCurrency` and `formatRelativeTime` from `useI18n()` use the
locale of the current render in every binding. Switch the locale and the formatted output changes
with the text.

`src/PriceTag.tsx`

```tsx

export function PriceTag(props: { amount: number }) {
const { formatCurrency } = useI18n();
return <span>{formatCurrency(props.amount, 'EUR')}</span>;
}
```

In Solid the formatter reads the locale signal, so the JSX expression, a `createMemo` or a
`createEffect` that calls it re-runs on a switch, the same way `t()` does. The other bindings close
each formatter over the locale of the render. Every formatter also accepts a trailing locale when
you want to format in a locale other than the current one, a price in the shop's currency format
on a page rendered in the user's language for instance.

## Next.js server helpers take the locale explicitly

`getI18n({ locale })` and `<ComviProvider locale>` take the locale from the `[locale]` param,
and the no-argument `getI18n()` works only under that provider, after `setRequestLocale()`, or on
a request the middleware saw, which sets the `x-comvi-locale` header. A client can send that header
on a path the middleware skips, so it counts only when its value is in `routing.locales`, or, with
no routing configured, is a well-formed BCP 47 tag. Any other value is ignored: the request renders
in `routing.defaultLocale`, or without routing fails as if no locale had been named.
The page can render before its layout, so a helper that guessed the locale from the request
would sometimes find none; passing it costs one argument and never does. See
[Next.js](/docs/i18n/next).

## The server reads the URL first, then the cookie, and writes the cookie for a year

On Next.js and Nuxt the locale of a request is the URL path prefix when there is one. Only then
do the sources in `localeDetection.order` run, by default `['query', 'cookie', 'accept-language']`,
and `query` reads nothing until `queryParam` names a parameter, so by default that is the
cookie, then the `Accept-Language` header, then `defaultLocale`.

`src/i18n/routing.ts`

```ts

export const routing = defineRouting({
locales: ['en', 'uk'],
defaultLocale: 'en',
localeCookie: false, // persist nothing; the URL alone carries the locale
});
```

The same two options, `localeCookie` and `localeDetection`, with the same values and defaults,
go under `comvi` in `nuxt.config.ts`. The cookie is written on a page load whenever the resolved
locale differs from the cookie the browser sent: `NEXT_LOCALE` on Next.js, `i18n_locale` on Nuxt, for one year, `SameSite=Lax`,
`Secure` outside development, and scoped to the current domain unless `domain` says otherwise. The URL
wins because it is what the visitor shared and what a crawler indexed; a cookie that overrode it
would show two people two languages at one address. The cookie comes second because a returning
visitor's choice should survive a bare `/`, and the browser's header comes last because it is a
guess about the person, not a choice they made.

## Nuxt writes `<html lang>` and `<html dir>` for you

`@comvi/nuxt` sets both from the active locale on the server and on every switch, so a
right-to-left locale arrives right-to-left on first paint; `comvi.head: false` hands `<html>`
back to you. The other bindings leave the attributes to the app, as on
[Switch the locale](/docs/i18n/language-switching).

## A locale detector runs once, inside `init()`

The detector reads the query string, storage, a cookie or `navigator.language`, in the order
you configure, and sets the instance's locale before the first catalog loads. It runs when `init()`
runs, and `init()` runs once per instance. Without `supportedLocales`, any language the browser
reports becomes the active locale and is cached; development warns about that.

`src/i18n.ts`

```ts

export const i18n = createI18n({ locale: 'en' }).use(
localeDetector({
    supportedLocales: ['en', 'uk'],
    order: ['querystring', 'cookie', 'navigator'],
    caches: ['cookie'],
}),
);
```

Every source reads from `window` or `document`, and a missing `window` falls through to the
next source, so on a server the detector finds nothing and returns the `locale` option. That is
harmless on a per-request instance. On the Next.js server the instance is one per process, so a detector
composed there runs once, when the first request initializes the instance, and never looks at a
request again. Compose it on the client only, with
`nextPlugin(localeDetectorPlugin(…), { runtime: 'client' })`, and read the request's cookie or
`Accept-Language` header in the middleware for the server side. The detector is client-side by
design because that is where the signal lives: the browser knows the user's language, and a
server knows it only through what the browser sends.

## Read next

- [Handle missing keys and errors](/docs/i18n/error-handling): `onMissingKey`, `onError` and `strict` in one place.
- [Load translations on demand](/docs/i18n/loading-translations): what `init()`, `setLocale()` and `reloadMessages()` fetch.
- [Fetch loader](/docs/i18n/plugins/fetch-loader): every option of the CDN and API loaders.
