# Load translations on demand

Load catalogs on demand with an import map, a loader function or the Comvi CDN, and activate namespaces as routes need them.

Canonical: https://comvi.io/docs/i18n/loading-translations

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](/docs/i18n/quick-start) and swap its inline `messages` for one of the loaders
here; the CDN one also needs a Comvi project.

## Import maps

`src/i18n.ts`

```ts

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

`src/i18n.ts`

```ts

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

`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'),
      '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](/docs/i18n/connect-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](/docs/i18n/plugins/fetch-loader).

## Nested catalogs

`src/i18n.ts`

```ts

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

`src/routes/dashboard.ts`

```ts

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](/docs/i18n/namespaces).

## Loading state
**React**

`src/App.tsx`

```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>;
}
```

**Vue**

`src/App.vue`

```vue
<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>
```

**Nuxt**

`nuxt.config.ts`

```ts
import { defineNuxtConfig } from 'nuxt/config';

export default defineNuxtConfig({
    modules: ['@comvi/nuxt'],
    comvi: { defaultLocale: 'en', locales: [{ code: 'en', name: 'English' }] },
});
```

`pages/index.vue`

```vue
<script setup lang="ts">
const { t, isLoading } = useI18n();
</script>

<template>
    <p v-if="isLoading">Loading…</p>
    <h1 v-else>{{ t('page.title') }}</h1>
</template>
```

**Svelte**

`src/App.svelte`

```svelte
<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}
```

**Solid**

`src/App.tsx`

```tsx
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>

    );
}
```

**Vanilla**

`src/loading.ts`

```ts
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

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

- [Fetch loader](/docs/i18n/plugins/fetch-loader): CDN against API mode, fallback, caching.
- [Split translations into namespaces](/docs/i18n/namespaces): split keys per page and load them on route change.
- [Handle missing keys and errors](/docs/i18n/error-handling): `loadError`, `onLoadError` and `onError`.
