Skip to content
Comvi

Load translations on demand

View .md

Load each locale when the user needs it instead of bundling every catalog. A loader is composed with .use() in the same expression that creates the instance. Start from the Quick start and swap its inline messages for one of the loaders here; the CDN one also needs a Comvi project.

src/i18n.ts
import { createI18n } from '@comvi/core';
import { loader } from '@comvi/core/loader';
export const i18n = createI18n({ locale: 'en', fallbackLocale: 'en' }).use(
loader({
'en': () => import('./locales/en.json'),
'de': () => import('./locales/de.json'),
'de:admin': () => import('./locales/de.admin.json'),
}),
);
await i18n.init();

loader(map) turns each entry into a dynamic import(), so the bundler emits one chunk per locale and a locale is fetched only when it is active or in the fallback chain. A key is 'locale' for the default namespace or 'locale:namespace' for one namespace of one locale. init() loads the initial namespaces for the active locale and for each locale of the fallback chain; setLocale(), addActiveNamespace() and reloadMessages() go through the same map later. Framework providers call init() for you.

The snippet imports from @comvi/core. An app that installed only a binding adds @comvi/core to its own dependencies first: it arrives as a transitive dependency, and pnpm does not hoist one into the app’s node_modules, so the import does not resolve there.

src/i18n.ts
import { createI18n } from '@comvi/core';
import { attachLoader } from '@comvi/core/loader';
export const i18n = createI18n({ locale: 'en', fallbackLocale: 'en' }).use(attachLoader);
i18n.registerLoader(async (locale, namespace) => {
const response = await fetch(`/api/translations/${locale}/${namespace}`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
});
await i18n.init();

attachLoader adds the loader capability without a map, and registerLoader() takes any (locale, namespace) => Promise<catalog> function: your own API, a database, the file system. A throw inside it emits loadError and the namespace stays empty.

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'),
'de': () => import('./locales/de.json'),
},
}),
);
await i18n.init();

cdnLoader() fetches each locale from the Comvi CDN and composes the loader for you; the endpoint comes from a project on Connect to Comvi. The fallback map uses the same keys as an import map and serves a chunk when the network fails. The browser caches CDN responses through their HTTP headers. Options, API mode and the namespace layout are on Fetch loader.

src/i18n.ts
import { createI18n, flattenCatalog } from '@comvi/core';
const i18n = createI18n({ locale: 'en' });
i18n.addMessages({
'en': { 'nav.home': 'Home' },
'en:admin': { nav: { home: 'Admin home' } }, // resolves as "nav.home"
});
flattenCatalog({ nav: { home: 'Home' } }); // { "nav.home": "Home" }

Every catalog is flattened where it enters the instance: the messages option, a loader result and addMessages() all accept flat dot keys, nested objects or a mix. addMessages() merges into the existing catalog, key by key. A number, boolean or array leaf is coerced with String(); a null or undefined leaf is dropped, so its key is missing. Each case logs one development warning naming the key. flattenCatalog from @comvi/core is the same function, for inspecting or merging data before you hand it over.

src/routes/dashboard.ts
import { i18n } from '../i18n';
await i18n.addActiveNamespace('dashboard');
// Later, stop serving it:
i18n.removeActiveNamespace('dashboard');

useI18n('dashboard') only scopes t() to that namespace; it loads nothing. A namespace becomes available through the ns: [...] option, loaded during init(), through a catalog passed as messages or addMessages(), or through addActiveNamespace() from a router guard. removeActiveNamespace() is synchronous: it cancels in-flight loads for that namespace and leaves the cached catalog in place. clearMessages() clears content only and never changes which namespaces are active. Route patterns are on Split translations into namespaces.

src/App.tsx
import { useI18n } from '@comvi/react';
export function App() {
const { t, isLoading } = useI18n();
if (isLoading) return <p>Loading…</p>;
return <h1>{t('page.title')}</h1>;
}

isLoading is true while init() runs and during any later load through the loader: a locale switch, a namespace activation, a reload. The first paint of a CDN-loaded app is a separate gate that needs no check in each component: the fallback prop in React and Solid, isInitializing in Vue and Svelte.

A loader is one function, (locale, namespace) => Promise<catalog>, and everything on this page is a way of producing it. loader(map) wraps the map: a request for (locale, namespace) looks up 'locale:namespace', falls back to the bare 'locale' key only for the default namespace, and throws E_REGISTER_LOADER_ENTRY when neither exists, so a typo in the map fails the load instead of returning an empty catalog. The default wrapper that dynamic import() puts around a JSON module is unwrapped for you. cdnLoader() builds the function from the endpoint and the layout, and registerLoader() takes yours as is.

init() runs in a fixed order. It installs plugins first, so a locale detector can change the locale before anything is fetched. It then activates the namespaces in ns, or the default namespace alone, and loads each one for the current locale and for each locale in fallbackLocale, all in parallel; a fallback catalog that fails to load is reported and does not fail init(). isLoading and isInitializing are both true for the whole span and initialized fires once at the end. The two flags differ afterwards: isInitializing is owned by init() and never turns on again, while isLoading turns on for every later load, a locale switch, a namespace activation or a reload. Gate the first paint on isInitializing and a later spinner on isLoading. A static messages catalog is in the cache before init() resolves, so t() works right away; init() still emits one short loading transition.

Each loaded pair goes into a cache keyed locale:namespace, flattened. A later setLocale() loads only the pairs that are not cached yet, so switching back to a locale fetches nothing, and reloadMessages() is the one call that refetches a cached pair. Nothing is evicted until clearMessages() or destroy(), so an instance with a fixed set of locales and namespaces is a bounded amount of memory.

Requests are deduplicated per pair. While a load for de:admin is in flight, every other request for de:admin, from a second component, a route guard or a locale switch, receives the same promise, and the loader function is called once. When the load settles, the entry leaves the in-flight set. A failed load leaves no cache entry behind either, so the next switch or reload calls the loader again rather than returning a cached rejection. reloadMessages() and removeActiveNamespace() cancel the in-flight loads they cover: a cancelled load neither writes to the cache nor reports an error when it eventually settles.

When a fetch fails, the layers answer in order. With cdnLoader(), the CDN request is tried once, with no retry, and then the fallback map is consulted for the same locale:namespace key, or for the bare locale key when the namespace is the default one; a chunk found there is served as if the CDN had answered, and when the map has no entry the original error stands. The loader then reports: loadError fires for the pair, onError receives it with source: 'namespace-load', and the namespace stays empty. One failed namespace among several does not fail the operation, so init() and setLocale() still resolve and the other namespaces land; only when every requested namespace fails do they reject. From then on t() does what it does for any key that is not in the cache: it walks the fallback locale chain in that namespace and, finding nothing, runs the missing-key path and renders the key. The user sees the fallback locale’s text where it exists and the key where it does not, until a retry fills the cache.