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
Section titled “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.
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.
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.
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.
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 parallelawait i18n.setLocale('fr'); // fetches fr.json; en.json is already in the cacheinit() 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()
Section titled “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.
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.
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.
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.
The server reads the URL first, then the cookie, and writes the cookie for a year
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.
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.
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.
Read next
Section titled “Read next”- Handle missing keys and errors:
onMissingKey,onErrorandstrictin one place. - Load translations on demand: what
init(),setLocale()andreloadMessages()fetch. - Fetch loader: every option of the CDN and API loaders.