Skip to content
Comvi

Migrate from vue-i18n

View .md

From a component’s point of view @comvi/vue looks like vue-i18n: useI18n(), t(), a global $t() and a component for rich content. The work is in the setup, where you compose the i18n instance instead of getting one ready-made, and in the message files, where vue-i18n’s pipe plurals and linked messages have no equivalent and are rewritten by hand, along with each plural call.

Feature vue-i18n @comvi/vue
Init createI18n({ legacy: false, locale, messages }) createI18n({ locale }), then .use(installer) on the wrapper
App registration app.use(i18n) app.use(i18n), which starts init() for you
Composable useI18n() useI18n(), same name and same locale name
Translation function t('key') t('key'), same
Interpolation named {name}, list {0} named {name} only
Pluralization pipe syntax no items | one | {count}, called as t('items', 3) ICU {count, plural, …}, called as t('items', { count: 3 }), with compiler: icuCompiler
Linked messages @:key, @.capitalize:key not supported; inline the value or pass a parameter
Rich text component <i18n-t keypath tag> <T i18nKey> with named slots or a components prop
Template globals $t(), $rt(), $te(), $d(), $n(), $i18n $t(), $tRaw(), $i18n
Number / date $n() / $d() with named formats formatNumber / formatDate in code, not ICU argument types
Per-component messages <i18n> blocks in SFCs, useI18n({ messages }) a namespace catalog such as "en:checkout", read with useI18n('checkout')
Translation source the messages option, setLocaleMessage() the messages option, or a loader: loader(importMap) or the Comvi CDN
Type safety MessageSchema generic or DefineLocaleMessage augmentation generated TranslationKeys augmentation
  1. Install the packages

    Terminal window
    npm install @comvi/vue @comvi/plugin-fetch-loader
    npm uninstall vue-i18n

    Remove @intlify/unplugin-vue-i18n and any @intlify/* build plugin too. Without that plugin an <i18n> block in a component is ignored without an error, and its keys render as the key itself. Move each block’s messages into a namespace catalog, such as "en:checkout", and read it with useI18n('checkout').

  2. Replace the createI18n call

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

    To keep the JSON in the bundle, pass messages: { en, de } in the same options object and compose no loader. To load a locale on demand, where vue-i18n calls setLocaleMessage() after a dynamic import, compose .use(loader({ de: () => import('./locales/de.json') })) with loader from @comvi/vue.

    createI18n from @comvi/vue returns a VueI18n wrapper, not a core instance. The wrapper is what app.use() installs, the i18n instance underneath it is i18n.core, and .use(installer) on the wrapper is how capabilities are composed onto that instance.

    There is no init() to write: app.use(i18n) starts it fire-and-forget, and an await i18n.init() you add anyway joins the attempt already running instead of starting a second one. Under SSR that await is the one you need: await i18n.init() must complete before renderToString(app), or rendering races translation loading and serializes an empty or stale cache.

    .use() carries the type. Each call returns the same wrapper retyped, so after .use(cdnLoader(…)) the call i18n.core.reloadMessages() type-checks. The same installer written as i18n.core.use(…) runs but leaves i18n.core typed as the base instance, and then that call does not compile.

  3. Update useI18n()

    src/components/Greeting.vue
    <script setup lang="ts">
    import { useI18n } from '@comvi/vue';
    const { t, locale } = useI18n();
    </script>
    <template>
    <p>{{ t('greeting', { name: 'Alice' }) }} ({{ locale }})</p>
    </template>

    Same name, same role, different import. locale stays a Ref<string>.

    vue-i18n Comvi Notes
    t(key, params) t(key, params) same
    t(key, n) t(key, { count: n }) the count is a named parameter, and the message becomes ICU
    t(key, list), t(key, defaultMsg) none named parameters only; rename {0} to a name
    locale locale Ref<string> in both. Assigning it calls setLocale(); with a loader the ref changes once the target catalog has loaded
    setLocaleMessage addMessages takes { [locale]: messages } or "locale:namespace" keys and merges; call clearMessages(locale) first to replace
    availableLocales loadedLocales ComputedRef<string[]> of locales whose catalogs are loaded, not every locale you support
    te(key) hasTranslationNow(key) on the instance an empty string counts as missing. Not on useI18n(): reach it as inject(I18N_INJECTION_KEY)!.hasTranslationNow(key), or on Nuxt as useNuxtApp().$i18n.hasTranslationNow(key)
    d(value, format) formatDate(value, options) from useI18n() in code, not in the message; already locale-bound
    n(value, format) formatNumber(value, options) from useI18n() in code, not in the message; already locale-bound
    tm(key), rt() none use structured keys

    The loader and plugin members are on their own composables: reloadMessages, addActiveNamespace and onLoadError come from useI18nLoader(), and onMissingKey from useI18nPlugins(). Each throws a message naming the fix on an instance that never composed that capability.

    src/legal.ts
    import { i18n } from './i18n';
    // ✗ hasTranslation returns a ComputedRef, always truthy
    if (i18n.hasTranslation('legal.notice')) {
    console.log('never skipped');
    }
    // ✓ hasTranslationNow returns a plain boolean
    if (i18n.hasTranslationNow('legal.notice')) {
    console.log('actually conditional');
    }

    te(key) ports to hasTranslationNow, not hasTranslation, and the literal port compiles. hasTranslation(key) returns a ComputedRef<boolean> that re-evaluates on locale, cache and config changes, and a Ref is always truthy, so an if on it always takes the branch. Keep hasTranslation for templates and computed values, where Vue unwraps the ref. The same pairing applies to hasLocale / hasLocaleNow. Only the reactive pair is on useI18n(); hasTranslationNow and hasLocaleNow live on the VueI18n class, so inside a component that means inject(I18N_INJECTION_KEY)!.hasTranslationNow(key), and destructuring either name off useI18n() is a “property does not exist” type error.

  4. Replace <i18n-t> with <T>

    src/components/LegalNotice.before.vue
    <script setup lang="ts">
    import { useI18n } from 'vue-i18n';
    const { t } = useI18n();
    </script>
    <template>
    <!-- Message: "Read our {tos} for details" -->
    <i18n-t keypath="legal.notice" tag="p">
    <template #tos>
    <a href="/terms">{{ t('legal.tos_link') }}</a>
    </template>
    </i18n-t>
    </template>
    src/components/LegalNotice.vue
    <script setup lang="ts">
    import { T } from '@comvi/vue';
    </script>
    <template>
    <!-- Message: "Read our <tos>terms of service</tos> for details" -->
    <p>
    <T i18nKey="legal.notice">
    <template #tos="{ children }">
    <a href="/terms">{{ children }}</a>
    </template>
    </T>
    </p>
    </template>

    vue-i18n’s slots fill named placeholders; Comvi’s fill tags in the message, so the message text changes too. A named slot receives { children }, the tag’s contents from the message, so the link text lives in the catalog rather than in a second key. Attributes that are not <T> props become parameters, so <i18n-t plural="n"> becomes <T :count="n">. The components prop does the same job without a template, and wins a name collision with a slot of the same name. Never use v-html with a message: <T> builds real VNodes from the parsed message, so nothing is interpreted as markup that was not a recognised tag.

  5. $t() in templates

    src/components/Hero.vue
    <template>
    <h1>{{ $t('hello.world') }}</h1>
    <p>{{ $t('welcome', { name: 'Alice' }) }}</p>
    <p>{{ $t('items', { count: 3 }) }}</p>
    <small>{{ $i18n.locale }}</small>
    </template>

    Installing the plugin with app.use(i18n) registers three global properties: $t, $tRaw and $i18n. Templates using $t() with a key or named parameters need no change. A plural call such as $t('items', 3) passes { count: 3 } instead. $d(), $n(), $te() and $rt() have no global: format in code and pass the result as a parameter.

  6. Migrate the message files

    src/locales/en.before.json
    {
    "greeting": "Hello, {name}!",
    "welcome": "@:greeting Welcome to our app.",
    "items": "no items | one item | {count} items",
    "balance": "Balance: {amount}",
    "hello": { "world": "Hello world" },
    "legal": { "notice": "Read our {tos} for details", "tos_link": "terms of service" }
    }
    src/locales/en.json
    {
    "greeting": "Hello, {name}!",
    "welcome": "Hello, {name}! Welcome to our app.",
    "items": "{count, plural, =0 {no items} one {one item} other {# items}}",
    "balance": "Balance: {amount}",
    "hello": { "world": "Hello world" },
    "legal": { "notice": "Read our <tos>terms of service</tos> for details" }
    }
    src/components/Balance.vue
    <script setup lang="ts">
    import { useI18n } from '@comvi/vue';
    const { t, formatCurrency } = useI18n();
    </script>
    <template>
    <p>{{ t('balance', { amount: formatCurrency(1234.56, 'USD') }) }}</p>
    </template>

    The JSON structure carries over, nesting included; three syntaxes inside the values do not, and each is a manual pass over the files. Pipe plurals become ICU: inside an ICU branch # is the count, so other {# items} is what {count} items used to be. ICU needs compiler: icuCompiler from step 2, whether the catalogs are inline or come from a loader. Linked messages, @:greeting and @.capitalize:greeting, have no equivalent: inline the value, as welcome does, pass it as a parameter, or apply the modifier in code. Number and date formatting moves into code, as balance shows.

    Every plural call changes with its message: t('items', 3) becomes t('items', { count: 3 }), because ICU reads the count from the named parameter. In TypeScript the old call is a type error. In plain JavaScript it renders the ICU source text and warns about an invalid plural parameter.

    useI18n() already returns formatNumber, formatDate, formatCurrency and formatRelativeTime bound to the current locale, so a component needs neither the instance nor a second package. Do not import formatCurrency from @comvi/core: this migration never installs @comvi/core as a direct dependency, and the free formatter functions are not among the core values @comvi/vue re-exports; use the bound ones from useI18n().

    Then import each locale’s JSON in your project on app.comvi.io and let the CDN serve it to the loader you composed in step 2.

  7. Remove vue-i18n

    Terminal
    grep -r "vue-i18n\|@intlify" src/ --include="*.ts" --include="*.vue"

    Then drop vue-i18n, the @intlify build plugin and its config, and any vue-i18n.d.ts / locale type declarations.

Pattern vue-i18n Comvi
Interpolation Hello, {name}! Hello, {name}!, same
Plural no items | {count} item | {count} items {count, plural, =0 {no items} one {# item} other {# items}}
Ordinal not built-in {n, selectordinal, one {#st} two {#nd} other {#th}}
Gender not built-in {gender, select, male {…} female {…} other {…}}
Linked message @:greeting, world! inline the value, or pass a parameter
Linked with modifier @.capitalize:greeting not supported; handle it in code
Number format $n(v, 'currency') formatCurrency(v, 'USD') from useI18n(), interpolated as {v}
Date format $d(v, 'long') formatDate(v, options) from useI18n(), interpolated as {v}
Rich text <i18n-t keypath> <T i18nKey> with slots or components