Skip to content
Comvi

Vue

View .md

With a Vue 3 app, @comvi/vue is a plugin: app.use(i18n) at the root, useI18n() in components, and <T> for translations that carry markup. t() is reactive the way a computed is, so a locale switch re-renders exactly the templates that call it.

The Vue example in StackBlitz runs the switcher, plurals and <T> with a link inside the text, and fetches each catalog the first time its locale is selected.

Terminal window
npm install @comvi/vue

@comvi/vue peer-depends on vue (^3) and brings @comvi/core.

src/main.ts
import { createApp } from 'vue';
import { createI18n } from '@comvi/vue';
import App from './App.vue';
const i18n = createI18n({
locale: 'en',
fallbackLocale: 'en',
messages: { en: { greeting: 'Hello, {name}!' }, uk: { greeting: 'Привіт, {name}!' } },
});
createApp(App).use(i18n).mount('#app');

createI18n() returns the i18n instance wrapped for Vue: locale is a ref, isInitializing a readonly ref, and t() tracks both. app.use(i18n) provides the instance to every component, registers $t, $tRaw and $i18n, and starts init() fire-and-forget. The two uses are different verbs: app.use(i18n) is Vue’s plugin install, and Comvi’s .use(installer) composes a capability onto the instance. The options are on the Quick start.

src/App.vue
<script setup lang="ts">
import { useI18n } from '@comvi/vue';
const { t, locale, setLocale } = useI18n();
</script>
<template>
<h1>{{ t('greeting', { name: 'Alice' }) }} ({{ locale }})</h1>
<button @click="setLocale('uk')">Українська</button>
</template>

t() inside a template or a computed reads the locale ref and the cache revision, so it re-evaluates when either changes and at no other time. A t() called once in setup() and stored in a plain const is computed once. locale is a Ref<string>: setLocale() resolves once the new locale’s catalog is loaded, and assigning locale.value = 'uk' triggers the same switch. useI18n('admin') binds a namespace, and dir is a ComputedRef<"ltr" | "rtl">.

src/locales/en.json
{
"help": "Read <link>our docs</link> or <bold>contact us</bold>.",
"legal.tos": "Read our <tos>Terms of Service</tos>"
}
src/Help.vue
<script setup lang="ts">
import { T } from '@comvi/vue';
</script>
<template>
<T i18nKey="help">
<template #link="{ children }">
<a href="/docs">{{ children }}</a>
</template>
<template #bold="{ children }">
<strong>{{ children }}</strong>
</template>
</T>
<T i18nKey="legal.tos" :components="{ tos: { component: 'a', props: { href: '/terms' } } }" />
</template>

A named slot per tag receives the tag’s content as children. The components prop takes an HTML tag name, a Vue component or a { component, props } config, and wins over a slot of the same name. Every attribute other than i18nKey, params, ns, locale, fallback, markers and components is an interpolation param in camelCase, so class and style go on an element around <T>. Default slot content renders when the key is missing. Plain t() does not parse tags. See Tag interpolation.

src/main.ts
import { createApp } from 'vue';
import { createI18n } from '@comvi/vue';
import { cdnLoader } from '@comvi/plugin-fetch-loader';
import App from './App.vue';
const i18n = createI18n({
locale: 'en',
fallbackLocale: 'en',
}).use(cdnLoader({ cdnUrl: 'https://cdn.comvi.io/<project-token>' }));
createApp(App).use(i18n).mount('#app');

Install @comvi/plugin-fetch-loader and compose .use(cdnLoader()) before app.use(i18n). The install starts init(), and init() is the one thing that runs the queued plugins: an installer composed after it leaves its plugin waiting for a second init() that never comes, and development warns, opening with [@comvi/vue] .use(installer) ran after app.use(i18n) started init(). See Connect to Comvi.

The CDN endpoint URL is under Settings → Content Deploy in your Comvi project, once Content Deploy is enabled and saved; pass it as cdnUrl. The URL format is on CDN Deployment.

If your keys use ICU ({count, plural, …}), add compiler: icuCompiler as on Pluralize with ICU. The platform and the compiler are independent: either works without the other.

src/App.vue
<script setup lang="ts">
import { useI18n } from '@comvi/vue';
const { t, isInitializing } = useI18n();
</script>
<template>
<div v-if="isInitializing" class="spinner" />
<h1 v-else>{{ t('greeting', { name: 'Alice' }) }}</h1>
</template>

Vue has no fallback prop, so isInitializing gates the first paint in the component that owns the children, and raw keys never render. By the time the root component renders, init() has started and the ref is true; it drops to false on the initialized event. isLoading goes true again for every later load, which is why the gate is isInitializing and not isLoading.

server.ts
import { createSSRApp } from 'vue';
import { renderToString } from 'vue/server-renderer';
import { createI18n } from '@comvi/vue';
import App from './src/App.vue';
export async function render(localeFromRequest: string) {
const app = createSSRApp(App);
const i18n = createI18n({
locale: 'en',
ssrLocale: localeFromRequest,
messages: { en: { greeting: 'Hello' }, uk: { greeting: 'Привіт' } },
});
app.use(i18n);
await i18n.init();
try {
return await renderToString(app);
} finally {
await i18n.destroy(); // runs plugin cleanups; a collected instance never does
}
}

Build one instance per request. A module-scope instance is shared by every concurrent request, and one visitor’s setLocale() moves the locale under everyone else’s render. ssrLocale seeds the reactive ref and the core locale before the first render, so the client hydrates against the locale it was served; its catalogs have to be on the instance by then, inline or loaded. Await i18n.init() before renderToString(): app.use(i18n) starts it fire-and-forget, and a render that outruns the load serializes an empty cache. On Nuxt, @comvi/nuxt does all three per request. See Render on the server.

Installing the plugin adds $t(key, params?), $tRaw(key, params?) and $i18n to every component: {{ $t('greeting', { name: 'Alice' }) }} works in any template without useI18n(), and so does $i18n.setLocale('uk'). $i18n.locale is a ref.

createI18n() builds a core instance and wraps it in a VueI18n object. The wrapper keeps a handful of shallow refs: the locale, isLoading, isInitializing, isInitialized, a cache revision and a config revision. It subscribes once to the core’s revision events and mirrors each event into the matching ref: localeChanged writes the locale, namespaceLoaded and messagesCleared bump the cache revision, loadingStateChanged writes the two flags, initialized syncs everything. Components never touch the core’s events; they read refs.

t() reads the locale ref, the cache revision and the config revision, then calls the core. That is the whole reactivity model: any template, computed or watchEffect that calls t() depends on those three refs, so a locale switch, a namespace arriving, a clearMessages() or a change of default namespace re-evaluates it, and nothing else does. The formatters default to the same locale ref, so formatDate() in a template follows a switch too.

setLocale() goes through a queue on the wrapper, so two quick calls run in order rather than racing, and locale.value = 'uk' is the same call through a writable computed. Underneath, the core’s loader loads every active namespace for the new locale before it assigns the locale and emits localeChanged, so the ref changes once, after the catalog is complete, and the templates re-render with finished text rather than a half-translated frame.

app.use(i18n) runs install(): it provides the wrapper under the injection key useI18n() reads, registers the $t, $tRaw and $i18n globals, and starts init() if nothing has. init() raises isInitializing, runs the queued plugins, fetches the default namespace in the current locale and in every fallbackLocale entry, then sets isInitialized and emits initialized. A fallback catalog that fails to load is only reported. With an inline catalog the load is a cache hit; with a loader this is where the first fetch happens.

A missing key is not an error. The core looks in the current locale, then in each fallback locale in order, and when nothing matches it emits missingKey and returns the key itself, so a raw key in a template means a missing translation, not a broken plugin. strict: 'dev' adds a console warning in development.

On the server, init() is fire-and-forget from install() and nothing awaits it, so a render that starts before the load finishes serializes whatever is in the cache. Awaiting i18n.init() yourself, on an instance built for that request, is what makes the server HTML and the client’s first render agree.

what happened message
useI18n() in an app without app.use(i18n) [@comvi/vue] useI18n must be used within a Vue app with i18n plugin installed. Make sure you called app.use(i18n) before using this composable.
<T> in an app without app.use(i18n) [@comvi/vue] <T> component must be used within a Vue app with i18n plugin installed
useI18nLoader() on an instance with no loader composed development: [@comvi/core] This i18n instance has no loader capability. Compose it: .use(attachLoader) from "@comvi/core/loader", or loader(map).; a production build throws the short form quoted on Error codes

More messages and their fixes are on Troubleshooting.