Skip to content
Comvi

Split translations into namespaces

View .md

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; without one, namespaces still work as locale:namespace keys in a bundled catalog.

src/i18n.ts
import { createI18n } from '@comvi/core';
import { loader } from '@comvi/core/loader';
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.

src/i18n.ts
import { createI18n } from '@comvi/core';
import { cdnLoader } from '@comvi/plugin-fetch-loader';
export const i18n = createI18n({
locale: 'en',
defaultNs: 'storefront',
ns: ['storefront', 'default'],
}).use(
cdnLoader({
cdnUrl: 'https://cdn.comvi.io/<project-token>',
cdnLayout: { rootNamespace: 'default' },
}),
);
src/Dashboard.tsx
import { useI18n } from '@comvi/react';
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.

src/routes.ts
import { i18n } from './i18n';
// 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
import { useEffect } from 'react';
import { useI18nLoader } from '@comvi/react';
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.

src/settings-leave.ts
import { i18n } from './i18n';
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.

src/i18n.ts
import { createI18n } from '@comvi/core';
import defaultEn from './locales/en.json';
import dashboardEn from './locales/dashboard/en.json';
import defaultDe from './locales/de.json';
import dashboardDe from './locales/dashboard/de.json';
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.

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.