Migrate from vue-i18n
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.
Key differences
Section titled “Key differences”| 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 |
Migration steps
Section titled “Migration steps”-
Install the packages
Terminal window npm install @comvi/vue @comvi/plugin-fetch-loadernpm uninstall vue-i18nTerminal window pnpm add @comvi/vue @comvi/plugin-fetch-loaderpnpm remove vue-i18nTerminal window yarn add @comvi/vue @comvi/plugin-fetch-loaderyarn remove vue-i18nTerminal window bun add @comvi/vue @comvi/plugin-fetch-loaderbun remove vue-i18nRemove
@intlify/unplugin-vue-i18nand 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 withuseI18n('checkout'). -
Replace the
createI18ncallsrc/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 callssetLocaleMessage()after a dynamic import, compose.use(loader({ de: () => import('./locales/de.json') }))withloaderfrom@comvi/vue.createI18nfrom@comvi/vuereturns aVueI18nwrapper, not a core instance. The wrapper is whatapp.use()installs, the i18n instance underneath it isi18n.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 anawait 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 beforerenderToString(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 calli18n.core.reloadMessages()type-checks. The same installer written asi18n.core.use(…)runs but leavesi18n.coretyped as the base instance, and then that call does not compile. -
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.
localestays aRef<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 namelocalelocaleRef<string>in both. Assigning it callssetLocale(); with a loader the ref changes once the target catalog has loadedsetLocaleMessageaddMessagestakes { [locale]: messages }or"locale:namespace"keys and merges; callclearMessages(locale)first to replaceavailableLocalesloadedLocalesComputedRef<string[]>of locales whose catalogs are loaded, not every locale you supportte(key)hasTranslationNow(key)on the instancean empty string counts as missing. Not on useI18n(): reach it asinject(I18N_INJECTION_KEY)!.hasTranslationNow(key), or on Nuxt asuseNuxtApp().$i18n.hasTranslationNow(key)d(value, format)formatDate(value, options)fromuseI18n()in code, not in the message; already locale-bound n(value, format)formatNumber(value, options)fromuseI18n()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,addActiveNamespaceandonLoadErrorcome fromuseI18nLoader(), andonMissingKeyfromuseI18nPlugins(). 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 truthyif (i18n.hasTranslation('legal.notice')) {console.log('never skipped');}// ✓ hasTranslationNow returns a plain booleanif (i18n.hasTranslationNow('legal.notice')) {console.log('actually conditional');}te(key)ports tohasTranslationNow, nothasTranslation, and the literal port compiles.hasTranslation(key)returns aComputedRef<boolean>that re-evaluates on locale, cache and config changes, and aRefis always truthy, so anifon it always takes the branch. KeephasTranslationfor templates and computed values, where Vue unwraps the ref. The same pairing applies tohasLocale/hasLocaleNow. Only the reactive pair is onuseI18n();hasTranslationNowandhasLocaleNowlive on theVueI18nclass, so inside a component that meansinject(I18N_INJECTION_KEY)!.hasTranslationNow(key), and destructuring either name offuseI18n()is a “property does not exist” type error. -
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">. Thecomponentsprop does the same job without a template, and wins a name collision with a slot of the same name. Never usev-htmlwith a message:<T>builds real VNodes from the parsed message, so nothing is interpreted as markup that was not a recognised tag. -
$t()in templatessrc/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,$tRawand$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. -
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, soother {# items}is what{count} itemsused to be. ICU needscompiler: icuCompilerfrom step 2, whether the catalogs are inline or come from a loader. Linked messages,@:greetingand@.capitalize:greeting, have no equivalent: inline the value, aswelcomedoes, pass it as a parameter, or apply the modifier in code. Number and date formatting moves into code, asbalanceshows.Every plural call changes with its message:
t('items', 3)becomest('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 returnsformatNumber,formatDate,formatCurrencyandformatRelativeTimebound to the current locale, so a component needs neither the instance nor a second package. Do not importformatCurrencyfrom@comvi/core: this migration never installs@comvi/coreas a direct dependency, and the free formatter functions are not among the core values@comvi/vuere-exports; use the bound ones fromuseI18n().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.
-
Remove vue-i18n
Terminal grep -r "vue-i18n\|@intlify" src/ --include="*.ts" --include="*.vue"Then drop
vue-i18n, the@intlifybuild plugin and its config, and anyvue-i18n.d.ts/ locale type declarations.
Syntax comparison reference
Section titled “Syntax comparison reference”| 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 |
Read next
Section titled “Read next”- Vue: all of
@comvi/vue: the wrapper, its core, composables and<T>. - Pluralize with ICU: which compiler you have, and exactly what ICU implements.
- Format numbers, dates and currency:
formatNumber,formatCurrency,formatDateand text direction.