# Split translations into namespaces

Split a catalog into namespaces, load each one only where it is used, and scope a component's t() to one namespace.

Canonical: https://comvi.io/docs/i18n/namespaces

Split the catalog into namespaces and load each one only on the screens that use it. This
builds on the loader from [Load translations on demand](/docs/i18n/loading-translations);
without one, namespaces still work as `locale:namespace` keys in a bundled catalog.

## Define namespaces

`src/i18n.ts`

```ts

export const i18n = createI18n({
locale: 'en',
defaultNs: 'default',
ns: ['default', 'dashboard'],
}).use(
loader({
    'en': () => import('./locales/en.json'),
    'en:dashboard': () => import('./locales/dashboard/en.json'),
    'en:settings': () => import('./locales/settings/en.json'),
    'de': () => import('./locales/de.json'),
    'de:dashboard': () => import('./locales/dashboard/de.json'),
    'de:settings': () => import('./locales/settings/de.json'),
}),
);
```

A namespace is a name; the loader map decides where its content comes from. Each map key is
`locale` for the default namespace or `locale:namespace` for another one, and each value is a
dynamic `import()`, so the bundler splits every namespace into its own chunk.

| Option | Default | Effect |
| --- | --- | --- |
| `defaultNs` | `'default'` | The namespace `t()` reads when the call names none. |
| `ns` | `[defaultNs]` | Namespaces to load during `init()`. `ns: []` loads none. |

`ns` needs the loader capability: on an instance with no `.use(loader(…))` the option loads
nothing, and development logs a warning that says so.

The Comvi CDN uses the same layout: the project's default namespace at `en.json` and every
other namespace in its own folder. When the app's `defaultNs` differs from the namespace the
platform keeps at the root, set `cdnLayout.rootNamespace` on the
[fetch loader](/docs/i18n/plugins/fetch-loader#cdn-layout).

`src/i18n.ts`

```ts

export const i18n = createI18n({
locale: 'en',
defaultNs: 'storefront',
ns: ['storefront', 'default'],
}).use(
cdnLoader({
    cdnUrl: 'https://cdn.comvi.io/<project-token>',
    cdnLayout: { rootNamespace: 'default' },
}),
);
```

## Read a namespaced key

`src/Dashboard.tsx`

```tsx

export function Dashboard() {
const { t, isLoading } = useI18n('dashboard');

if (isLoading) return <div>Loading...</div>;

return (
    <>
      <h1>{t('page.title')}</h1>
      <p>{t('stats.summary')}</p>
      <button>{t('logout', { ns: 'auth' })}</button>
    </>
);
}
```

`useI18n('dashboard')` makes `dashboard` the namespace for every `t()` in the component, and
`{ ns }` on one call still reads another. With generated keys the scoped `t()` accepts only that
namespace's keys. On the instance, `i18n.t('page.title', { ns: 'dashboard' })` does the same.
There is no `namespace:key` shorthand: `t('dashboard:welcome')` reads the literal key
`dashboard:welcome` in the default namespace.

Scoping loads nothing. The namespace has to be in `ns`, or loaded at runtime.

## Load a namespace at runtime

`src/routes.ts`

```ts

// A route loader runs before its component renders, so the namespace is in
// place by first paint. Hand the array to createBrowserRouter().
export const routes = [
{
    path: '/settings',
    loader: async () => {
      await i18n.addActiveNamespace('settings');
      return null;
    },
},
];
```

`addActiveNamespace()` loads the namespace for the current locale and for each locale in
`fallbackLocale`, and keeps it active, so a later locale switch fetches it for the new locale too. `addActiveNamespaces([...])` takes
several at once. Both live on the loader capability: `.use(loader(map))` or
`.use(cdnLoader({ cdnUrl }))` on the instance, or `useI18nLoader()` in a component.

`src/SettingsGate.tsx`

```tsx

export function SettingsGate() {
const { addActiveNamespace } = useI18nLoader();
useEffect(() => {
    void addActiveNamespace('settings');
}, [addActiveNamespace]);
return null;
}
```

A namespace a screen keeps needing goes through `addActiveNamespace()` rather than a one-off
`addMessages()`: the first follows the user across locales, the second adds content for one
locale only.

## Deactivate a namespace

`src/settings-leave.ts`

```ts

i18n.removeActiveNamespace('settings');
```

`removeActiveNamespace()` is synchronous. It drops the namespace from the active set, cancels
its in-flight loads, and leaves the cache alone, so content already loaded keeps resolving.
Dropping content is a separate call, `clearMessages(undefined, 'settings')`, which never
deactivates anything.

## Static catalogs

`src/i18n.ts`

```ts

export const i18n = createI18n({
locale: 'en',
defaultNs: 'default',
messages: {
    'en:default': defaultEn,
    'en:dashboard': dashboardEn,
    'de:default': defaultDe,
    'de:dashboard': dashboardDe,
},
});
```

A bundled catalog takes the same `locale:namespace` keys. Every namespace in `messages` is
active from the start, so `ns` is only needed when a loader should fetch more during `init()`.
The imported files may be flat or nested; the instance flattens nested JSON on ingestion.

## How it works

A namespace is a second coordinate on the cache. Every catalog is stored under a
`locale:namespace` key, and `t()` picks one slot per call: the locale is `params.locale` or the
current one, the namespace is `params.ns` or `defaultNs`, and `useI18n('dashboard')` is a `t()`
with `ns: 'dashboard'` filled in. The key itself is never parsed for a namespace, which is why
`t('dashboard:welcome')` is a lookup for a key with a colon in it in the default namespace.
A `{ ns }` on one call overrides the scope for that call alone, and the lookup stays in that
namespace all the way down: the fallback locale chain is walked for the same namespace, and the
missing-key path reports that namespace.

While a slot is empty, because the namespace is still loading or was never activated, a key
from it goes down the same path as any other missing key. It walks the fallback locales in the
same namespace, emits `missingKey` and renders as itself. That is why the `isLoading` check in
`Dashboard` is what keeps the first paint from showing raw keys. Once the catalog lands,
`namespaceLoaded` fires and every component that read the instance re-renders with the text.

The active set is what the loader works from. `init()` activates every namespace in `ns` and
loads each one for the current locale and for each locale in `fallbackLocale`, in parallel,
before it resolves. `setLocale('de')` fetches `de` for every namespace in the set and nothing
else: the set is the list of namespaces the user currently needs, so a switch brings all of them
along. `addActiveNamespace()` adds one and loads it the way `init()` does;
`removeActiveNamespace()` drops it and cancels its in-flight loads but keeps its cache entries,
so a screen the user comes back to renders from the cache. `addMessages()` activates every
namespace it carries; a loader refetch writes to the cache without activating anything. With
nothing active, `reloadMessages()` falls back to the default namespace and says so in
development; a locale switch has no such fallback and fetches nothing.

Concurrent requests for one `(locale, namespace)` pair share one loader call, and a namespace
whose load fails stays active, so the next locale switch or reload retries it. A namespace
outside the active set and outside any `messages` catalog is unknown to the instance until
something activates it, and its keys render as themselves.

On the Comvi CDN the default namespace is the file at the root, `en.json`, and every other
namespace lives in a folder of its own, so the map the CDN loader builds has the same shape as
an import map. `cdnLayout.rootNamespace` names which namespace the root file is when the app's
`defaultNs` is a different one.

## Read next

- [Load translations on demand](/docs/i18n/loading-translations): loaders, caching and reload.
- [Translate text with t()](/docs/i18n/translation-function): `t()`, call options and missing keys.
- [`@comvi/core/loader`](/docs/i18n/api/core-loader): every method on the loader capability.
