Skip to content
Comvi

Important defaults

View .md

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.

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
import { createI18n } from '@comvi/core';
import { icuCompiler } from '@comvi/core/icu';
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

Section titled “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
import { createI18n } from '@comvi/core';
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

Section titled “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
import { createI18n } from '@comvi/core';
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

Section titled “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
import { createI18n } from '@comvi/core';
import { cdnLoader } from '@comvi/plugin-fetch-loader';
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() 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
import { createRoot } from 'react-dom/client';
import { createI18n, I18nProvider } from '@comvi/react';
import { cdnLoader } from '@comvi/plugin-fetch-loader';
import App from './App';
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" />}>
<App />
</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

Section titled “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
import { createI18n } from '@comvi/core';
import { cdnLoader } from '@comvi/plugin-fetch-loader';
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

Section titled “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
import { useI18n } from '@comvi/solid';
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

Section titled “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.

Section titled “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
import { defineRouting } from '@comvi/next/routing';
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

Section titled “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.

A locale detector runs once, inside init()

Section titled “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
import { createI18n } from '@comvi/core';
import { localeDetector } from '@comvi/plugin-locale-detector';
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.