# Migrate from vue-i18n

Replace vue-i18n with @comvi/vue: setup with .use(), useI18n and $t, pipe plurals to ICU, linked messages, and <i18n-t> to <T>.

Canonical: https://comvi.io/docs/i18n/guides/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

| 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               |

> **Number and date formatting stays in code**
>
> `{v, number}`, `{d, date, long}`, `{t, time}` and the `::currency/USD` skeleton are not
> implemented. Each is an `E_ICU_SYNTAX` failure, not a wrong render. `$n()` / `$d()` become
> `formatNumber` / `formatDate` calls in code, and the message keeps a plain `{param}`.

## Migration steps

1. **Install the packages**
**npm**

     ```bash
     npm install @comvi/vue @comvi/plugin-fetch-loader
     npm uninstall vue-i18n
     ```

**pnpm**

     ```bash
     pnpm add @comvi/vue @comvi/plugin-fetch-loader
     pnpm remove vue-i18n
     ```

**yarn**

     ```bash
     yarn add @comvi/vue @comvi/plugin-fetch-loader
     yarn remove vue-i18n
     ```

**bun**

     ```bash
     bun add @comvi/vue @comvi/plugin-fetch-loader
     bun remove 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`

   ```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`

   ```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`

   ```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`

   ```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`

   ```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`

   ```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`

   ```vue
   <script setup lang="ts">
   import { T } from '@comvi/vue';
   </script>

   <template>
     <!-- Message: "Read our <tos>terms of service</tos> for details" -->
     <p>

         <template #tos="{ children }">
           <a href="/terms">{{ children }}</a>
         </template>

     </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`

   ```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`

   ```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`

   ```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`

   ```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](https://app.comvi.io)
   and let the CDN serve it to the loader you composed in step 2.

7. **Remove vue-i18n**

   `Terminal`

   ```bash
   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.

## 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

- [Vue](/docs/i18n/vue): all of `@comvi/vue`: the wrapper, its core, composables and `<T>`.
- [Pluralize with ICU](/docs/i18n/pluralization): which compiler you have, and exactly what ICU implements.
- [Format numbers, dates and currency](/docs/i18n/formatting): `formatNumber`, `formatCurrency`, `formatDate` and text direction.
