Skip to content

Vanilla JavaScript i18n

The @comvi/core package is the framework-agnostic foundation of Comvi. Use it directly when you don’t need a framework binding, or when building for environments like Web Components, static sites, or server-side scripts.

Terminal window
pnpm add @comvi/core
  1. Create the i18n instance with static translations

    src/i18n.ts
    import { createI18n } from '@comvi/core';
    const i18n = createI18n({
    locale: 'en',
    fallbackLocale: 'en',
    translation: {
    en: {
    'hello.world': 'Hello, World!',
    'welcome': 'Welcome, {name}!',
    'nav.home': 'Home',
    'nav.about': 'About',
    },
    de: {
    'hello.world': 'Hallo, Welt!',
    'welcome': 'Willkommen, {name}!',
    'nav.home': 'Startseite',
    'nav.about': 'Uber uns',
    },
    },
    });
    await i18n.init();
  2. Use the t() function

    i18n.t('hello.world'); // "Hello, World!"
    i18n.t('welcome', { name: 'Alice' }); // "Welcome, Alice!"

The t() function translates a key and optionally interpolates parameters.

i18n.t('hello.world'); // "Hello, World!"
i18n.t('welcome', { name: 'Alice' }); // "Welcome, Alice!"
i18n.t('page.title', { ns: 'dashboard' }); // Use a specific namespace
i18n.t('greeting', { locale: 'de' }); // Force a specific language
i18n.t('new.key', { fallback: 'Fallback' }); // Fallback for missing keys

Signature:

t(key: string, params?: TranslationParams): string
ParameterTypeDescription
keystring | nullDot-notation translation key. null returns ""
paramsobjectOptional interpolation values plus the special options ns, locale, fallback, and raw

See Translation Function for the full reference.

Read or write the locale property to change the active locale. The setter is fire-and-forget — it kicks off namespace loading for the new locale in the background and any failure surfaces via the loadError event:

console.log(i18n.locale); // "en"
i18n.locale = 'de';
console.log(i18n.t('hello.world')); // "Hallo, Welt!"

When you need to wait for the new locale’s translations before re-rendering, use setLocaleAsync() instead — it loads the active namespaces first and is safe to call rapidly (only the latest call wins):

await i18n.setLocaleAsync('de');
// Translations for "de" are loaded; the UI can re-render now
console.log(i18n.t('hello.world')); // "Hallo, Welt!"

Since there is no framework reactivity system, Comvi uses events to notify your code when state changes. Subscribe to events and update your UI manually.

Fires after the language changes and new translations are ready:

i18n.on('localeChanged', ({ from, to }) => {
console.log(`Language changed from ${from} to ${to}`);
updateUI();
});

Fires when translations finish loading for a namespace:

i18n.on('namespaceLoaded', ({ locale, namespace }) => {
console.log(`Translations loaded for: ${locale}/${namespace}`);
});

Fires when t() is called with a key that does not exist:

i18n.on('missingKey', ({ key, locale, namespace }) => {
console.warn(`Missing translation: "${key}" [${locale}/${namespace}]`);
});

Every on() call returns an unsubscribe function:

const unsubscribe = i18n.on('localeChanged', updateUI);
// Later, stop listening
unsubscribe();

Without a framework, you update the DOM yourself in event handlers. Here is a practical pattern using data-i18n attributes and textContent:

index.html
<h1 data-i18n="hello.world"></h1>
<p data-i18n="welcome" data-i18n-params='{"name":"Alice"}'></p>
<select id="language-switcher">
<option value="en">English</option>
<option value="de">Deutsch</option>
</select>
src/main.ts
import { createI18n } from '@comvi/core';
const i18n = createI18n({
locale: 'en',
fallbackLocale: 'en',
translation: {
en: {
'hello.world': 'Hello, World!',
'welcome': 'Welcome, {name}!',
},
de: {
'hello.world': 'Hallo, Welt!',
'welcome': 'Willkommen, {name}!',
},
},
});
function translateDOM() {
document.querySelectorAll('[data-i18n]').forEach((el) => {
const key = el.getAttribute('data-i18n')!;
const paramsAttr = el.getAttribute('data-i18n-params');
const params = paramsAttr ? JSON.parse(paramsAttr) : undefined;
el.textContent = i18n.t(key, params);
});
document.documentElement.lang = i18n.locale;
}
// Update DOM on language change
i18n.on('localeChanged', translateDOM);
// Wire up the language switcher
document.getElementById('language-switcher')!.addEventListener('change', (e) => {
i18n.locale = (e.target as HTMLSelectElement).value;
});
// Initial render
await i18n.init();
translateDOM();

Load translations from the Comvi CDN or any HTTP endpoint instead of bundling them:

import { createI18n } from '@comvi/core';
import { FetchLoader } from '@comvi/plugin-fetch-loader';
const i18n = createI18n({
locale: 'en',
fallbackLocale: 'en',
})
.use(FetchLoader({
cdnUrl: 'https://cdn.comvi.io/your-distribution-id',
}));
await i18n.init();

See Fetch Loader Plugin for all configuration options.

Automatically detect the user’s preferred language from the browser, URL, or cookies:

import { createI18n } from '@comvi/core';
import { FetchLoader } from '@comvi/plugin-fetch-loader';
import { LocaleDetector } from '@comvi/plugin-locale-detector';
const i18n = createI18n({
locale: 'en',
})
.use(FetchLoader({
cdnUrl: 'https://cdn.comvi.io/your-distribution-id',
}))
.use(LocaleDetector({
order: ['querystring', 'cookie', 'navigator'],
caches: ['cookie'],
}));

Plugins are chained with .use(). The order determines priority for detectors and the registration order for loaders:

const i18n = createI18n({ locale: 'en' })
.use(FetchLoader({ cdnUrl: '...' }))
.use(LocaleDetector({ order: ['cookie', 'navigator'] }));

Comvi i18n exposes a TranslationKeys interface from @comvi/core that you augment with your real keys. Once augmented, t() autocompletes keys and validates parameters at compile time:

import { createI18n } from '@comvi/core';
declare module '@comvi/core' {
interface TranslationKeys {
'hello.world': never; // no params
'welcome': { name: string }; // required param
'nav.home': never;
'nav.about': never;
}
}
const i18n = createI18n({
locale: 'en',
});
i18n.t('hello.world'); // OK — no params
i18n.t('welcome', { name: 'Alice' }); // OK
i18n.t('nonexistent.key'); // Type error
i18n.t('welcome'); // Type error: missing 'name'

For larger projects, generate the augmentation file from your translations using the Comvi CLI. The CLI reads .comvirc.json and emits a .d.ts that re-opens @comvi/core with all your keys typed:

Terminal window
npx comvi typegen
src/i18n.ts
import { createI18n } from '@comvi/core';
// Side-effect import — augments @comvi/core's TranslationKeys interface
import './types/i18n';
const i18n = createI18n({
locale: 'en',
});

See Type-Safe Translations for the full setup, including CLI configuration and CI integration.

Comvi works inside Web Components. Use the events API to update shadow DOM content:

import { createI18n } from '@comvi/core';
const i18n = createI18n({
locale: 'en',
translation: {
en: { 'greeting': 'Hello from the shadow!' },
de: { 'greeting': 'Hallo aus dem Schatten!' },
},
});
class MyWidget extends HTMLElement {
private unsubscribe?: () => void;
connectedCallback() {
this.attachShadow({ mode: 'open' });
this.render();
this.unsubscribe = i18n.on('localeChanged', () => {
this.render();
});
i18n.init();
}
disconnectedCallback() {
this.unsubscribe?.();
}
private render() {
const p = document.createElement('p');
p.textContent = i18n.t('greeting');
const shadow = this.shadowRoot!;
shadow.replaceChildren(p);
}
}
customElements.define('my-widget', MyWidget);

The most commonly used members of the I18n instance. This is the same surface every framework binding builds on. See the Core API Reference for the complete, method-by-method documentation — including the translation cache, the plugin-side hooks, and the window.__COMVI__ registry.

MemberTypeDescription
t(key, params?)(key, params?) => stringTranslate a key to plain text
tRaw(key, params?)(key, params?) => TranslationResultReturn structured output for advanced renderers
localestringGet or set the current locale. The setter is fire-and-forget — use setLocaleAsync() to await loading
isLoadingbooleanWhether translations are currently being loaded
isInitializingbooleanWhether init() is currently running
isInitializedbooleanWhether init() has completed successfully
dir'ltr' | 'rtl'Text direction for the current locale
init()() => Promise<this>Initialize the instance, run plugins, and load initial translations. Resolves to the instance (chainable)
destroy()() => Promise<void>Tear down the instance: unregister from window.__COMVI__, run plugin cleanups (LIFO), clear caches and listeners. Use it for SPA route teardown and tests
use(plugin, options?)(plugin, options?) => thisRegister a plugin (chainable)
setLocaleAsync(locale)(locale) => Promise<void>Switch locale and wait for translations to load (race-safe)
on(event, handler)(event, handler) => () => voidSubscribe to an event (returns an unsubscribe function)
addTranslations(translations)(Record<string, Record<string, TranslationValue>>) => voidMerge translations at runtime, keyed by 'locale' or 'locale:namespace'
hasTranslation(key, locale?, ns?, checkFallbacks?)(...) => booleanCheck if a translation key exists in cache (no lookup)
reloadTranslations(locale?, ns?)(locale?, ns?) => Promise<void>Reload translations from the registered loader
addActiveNamespace(ns) / addActiveNamespaces(ns[])(...) => Promise<void>Load and activate one or more namespaces
getActiveNamespaces() / getDefaultNamespace() / getLoadedLocales()() => string[] / () => string / () => string[]Inspect namespace and locale state
registerLoader(loader) / registerLocaleDetector(fn) / registerPostProcessor(fn)(...) => voidPlugin-side hooks (usually called from inside a plugin)
onMissingKey(cb) / onLoadError(cb)(cb) => () => voidSubscribe to missing keys / load errors (returns an unsubscribe function)
reportError(error, context?)(...) => voidReport an error to the configured onError handler