Load translations on demand
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.
Import maps
Section titled “Import maps”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.
Custom loader
Section titled “Custom loader”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.
CDN with a bundled fallback
Section titled “CDN with a bundled fallback”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.
Nested catalogs
Section titled “Nested catalogs”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.
Namespaces on demand
Section titled “Namespaces on demand”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.
Loading state
Section titled “Loading state”import { useI18n } from '@comvi/react';
export function App() { const { t, isLoading } = useI18n();
if (isLoading) return <p>Loading…</p>; return <h1>{t('page.title')}</h1>;}<script setup lang="ts">import { useI18n } from '@comvi/vue';
const { t, isLoading } = useI18n();</script>
<template> <p v-if="isLoading">Loading…</p> <h1 v-else>{{ t('page.title') }}</h1></template>import { defineNuxtConfig } from 'nuxt/config';
export default defineNuxtConfig({ modules: ['@comvi/nuxt'], comvi: { defaultLocale: 'en', locales: [{ code: 'en', name: 'English' }] },});<script setup lang="ts">const { t, isLoading } = useI18n();</script>
<template> <p v-if="isLoading">Loading…</p> <h1 v-else>{{ t('page.title') }}</h1></template><script lang="ts"> import { useI18n } from '@comvi/svelte';
const { t, isLoading } = useI18n();</script>
{#if $isLoading} <p>Loading…</p>{:else} <h1>{$t('page.title')}</h1>{/if}import { Show } from 'solid-js';import { useI18n } from '@comvi/solid';
export function App() { const { t, isLoading } = useI18n();
return ( <Show when={!isLoading()} fallback={<p>Loading…</p>}> <h1>{t('page.title')}</h1> </Show> );}import { createI18n } from '@comvi/core';
const i18n = createI18n({ locale: 'en' });
i18n.on('loadingStateChanged', ({ isLoading }) => { document.body.classList.toggle('is-loading', isLoading);});
await i18n.init();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.
How it works
Section titled “How it works”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.
Read next
Section titled “Read next”- Fetch loader: CDN against API mode, fallback, caching.
- Split translations into namespaces: split keys per page and load them on route change.
- Handle missing keys and errors:
loadError,onLoadErrorandonError.