# Migrate from i18next

Move an app from i18next to Comvi: createI18n plus composed capabilities instead of the mutable singleton, {{name}} to {name}, suffix plurals to ICU, <Trans> to <T>, and the plugin equivalents.

Canonical: https://comvi.io/docs/i18n/guides/migrate-from-i18next

`t()`, namespaces and the JSON-per-namespace file layout carry over from i18next; the instance
and the message syntax do not. i18next mutates a singleton through its own `.use()`, while
Comvi builds an i18n instance and composes capabilities onto it with `.use(installer)`, and
Comvi messages are ICU, so every `{{name}}` and every `_one` / `_other` key pair gets touched.

## Key differences

| Feature              | i18next                                    | Comvi                                                       |
| -------------------- | ------------------------------------------ | ------------------------------------------------------------------ |
| Instance             | mutable singleton, `i18next.use(…).init()` | `createI18n({ … })` returning an instance you compose with `.use(…)`   |
| Translation function | `t('key')`                                 | `t('key')`, same                                                   |
| Switching locale     | `i18next.changeLanguage('de')`             | `i18n.setLocale('de')`, which awaits the namespace load       |
| Interpolation        | `{{name}}` (double curly)                  | `{name}` (single curly, ICU standard)                              |
| Pluralization        | suffix keys `key_one` / `key_other`        | one key, ICU `{count, plural, …}`; needs `compiler: icuCompiler`   |
| Context              | suffix keys `key_male` / `key_female`      | ICU `{gender, select, …}`                                          |
| Nesting              | `$t(key)` inside a value                   | not supported; pass a parameter or make two `t()` calls            |
| Namespaces           | `ns` option, `t('ns:key')` or `{ ns }`     | `ns` option and `{ ns }`; a `ns:key` prefix is not parsed          |
| Backend plugin       | `i18next-http-backend`                     | `@comvi/plugin-fetch-loader`                                       |
| Language detection   | `i18next-browser-languagedetector`         | `@comvi/plugin-locale-detector`                                    |
| React binding        | `react-i18next` (`useTranslation`, `Trans`) | `@comvi/react` (`useI18n`, `<T>`)                                 |
| Vue binding          | `i18next-vue`                              | `@comvi/vue` (`useI18n`, `<T>`)                                    |

> **Two things ICU does not do**
>
> Comvi's ICU implements `plural`, `selectordinal` and `select`, and only those. The
> `{v, number}`, `{d, date}` and `{t, time}` argument types and the plural `offset:` form are
> not implemented: each reports `E_ICU_SYNTAX` and renders the message source as is. i18next's
> `{{val, number}}` therefore becomes a `formatNumber` call in code with a plain `{val}` left
> in the message. See [What ICU does not implement](/docs/i18n/icu-reference#what-icu-does-not-implement).
> 
> ICU itself is opt-in: the default compiler handles `{param}` and nothing else, so a catalog
> with plurals needs `compiler: icuCompiler` in the constructor options.

## Migration steps

1. **Install the packages**

   `Terminal`

   ```bash
   npm install @comvi/react @comvi/plugin-fetch-loader @comvi/plugin-locale-detector
   npm uninstall i18next react-i18next i18next-http-backend i18next-browser-languagedetector
   ```

   | Framework | Install                                                              | Uninstall                               |
   | --------- | -------------------------------------------------------------------- | --------------------------------------- |
   | React     | `@comvi/react @comvi/plugin-fetch-loader @comvi/plugin-locale-detector` | `i18next react-i18next i18next-http-backend i18next-browser-languagedetector` |
   | Next.js   | `@comvi/next @comvi/plugin-fetch-loader`                             | same as React                           |
   | Vue       | `@comvi/vue @comvi/plugin-fetch-loader @comvi/plugin-locale-detector` | `i18next i18next-vue i18next-http-backend i18next-browser-languagedetector` |
   | Nuxt      | `@comvi/nuxt @comvi/plugin-fetch-loader`                             | same as Vue                             |
   | Svelte    | `@comvi/svelte @comvi/plugin-fetch-loader @comvi/plugin-locale-detector` | `i18next i18next-http-backend i18next-browser-languagedetector` |
   | Solid     | `@comvi/solid @comvi/plugin-fetch-loader @comvi/plugin-locale-detector` | same as Svelte                          |
   | Vanilla   | `@comvi/core @comvi/plugin-fetch-loader @comvi/plugin-locale-detector` | same as Svelte                          |

2. **Replace the initialization**

   `src/i18n.before.ts`

   ```ts
   import i18next from 'i18next';
   import { initReactI18next } from 'react-i18next';
   import HttpBackend from 'i18next-http-backend';
   import LocaleDetector from 'i18next-browser-languagedetector';

   i18next
     .use(HttpBackend)
     .use(LocaleDetector)
     .use(initReactI18next)
     .init({
       fallbackLng: 'en',
       ns: ['common', 'dashboard'],
       defaultNS: 'common',
       backend: { loadPath: '/locales/{{lng}}/{{ns}}.json' },
       detection: { order: ['querystring', 'cookie', 'navigator'], caches: ['cookie'] },
     });
   ```
**React**

     `src/i18n.ts`

     ```ts
     import { createI18n, icuCompiler } from '@comvi/react';
     import { cdnLoader } from '@comvi/plugin-fetch-loader';
     import { localeDetector } from '@comvi/plugin-locale-detector';

     export const i18n = createI18n({
       locale: 'en',
       fallbackLocale: 'en',
       ns: ['common', 'dashboard'],
       defaultNs: 'common',
       compiler: icuCompiler,
     })
       .use(
         localeDetector({
           supportedLocales: ['en', 'uk'], // without it a detected tag is truncated to its base language
           order: ['querystring', 'cookie', 'navigator'],
           caches: ['cookie'],
           lookupCookie: 'i18next', // the cookie name i18next wrote
           cacheFirst: false,
         }),
       )
       .use(cdnLoader({ cdnUrl: 'https://cdn.comvi.io/<project-token>' }));

     await i18n.init();
     ```

     `createI18n` from `@comvi/react` is core's constructor. Wrap your tree in
     `<I18nProvider i18n={i18n}>` in step 5.

**Next.js**

     `src/i18n/config.ts`

     ```ts
     import { createNextI18n, icuCompiler } from '@comvi/next';
     import { cdnLoader } from '@comvi/plugin-fetch-loader';

     // `.use()` takes the installer. `cdnLoaderPlugin` is the raw plugin for
     // `registerPlugin(…)` and throws `E_USE_NOT_INSTALLER` here.
     export const { i18n, routing } = createNextI18n({
       locales: ['en', 'de'],
       defaultLocale: 'en',
       fallbackLocale: 'en',
       ns: ['common', 'dashboard'],
       defaultNs: 'common',
       compiler: icuCompiler,
     }).use(cdnLoader({ cdnUrl: 'https://cdn.comvi.io/<project-token>' }));
     ```

     `createNextI18n` returns an instance with the loader and plugin capabilities, the tag
     grammar and discovery in place; the CDN loader is composed with `.use()`, and ICU is the
     `compiler` option, as on every constructor. Then `next.config.ts` exports through
     `withComvi({ config: './src/i18n/config.ts' })` from `@comvi/next/plugin`, which points
     the server helpers at this module; without it `getI18n()` throws in every Server
     Component. There is no locale detector: Next resolves the locale from the URL through
     `createMiddleware(routing)`. See
     [Next.js](/docs/i18n/next).

**Vue**

     The React file with the imports from `@comvi/vue` and no `await i18n.init()` in it.
     `createI18n` builds a `VueI18n` wrapper, and `.use()` on the wrapper composes onto
     the instance it holds. In `main.ts`, `app.use(i18n)` starts `init()`
     fire-and-forget when the core is not initialized, and an `await i18n.init()` you
     add joins the attempt already running. Keep the `.use()` on the wrapper:
     `i18n.core.use(…)` runs but leaves `i18n.core` typed as the base instance, so
     capability members do not type-check.

**Nuxt**

     `nuxt.config.ts`

     ```ts
     export default defineNuxtConfig({
       modules: ['@comvi/nuxt'],
       comvi: {
         locales: ['en', 'de'],
         defaultLocale: 'en',
         defaultNs: 'common',
       },
     });
     ```

     `comvi.i18n.ts`

     ```ts
     import { createI18n, defineComviI18n, icuCompiler } from '@comvi/nuxt/i18n';
     import { cdnLoader } from '@comvi/plugin-fetch-loader';

     export default defineComviI18n((options) =>
       createI18n({ ...options, compiler: icuCompiler }).use(
         cdnLoader({ cdnUrl: 'https://cdn.comvi.io/<project-token>' }),
       ),
     );
     ```

     The module generates the i18n instance, and without a `comvi.i18n.ts` file next to
     `nuxt.config.ts` it is the base one: text and `{param}` only. The factory returns
     a new instance on every call. `@comvi/nuxt` has no CDN option, so the loader is
     composed in `comvi.i18n.ts`. The factory receives Nuxt's resolved options, so
     `nuxt.config` still governs locale, fallback and namespaces. Configure locale
     detection through `comvi.localeDetection` and the cookie through
     `comvi.localeCookie`, not through `@comvi/plugin-locale-detector`.

**Svelte**

     The React file with the imports from `@comvi/svelte` and no `await i18n.init()`:
     `setI18nContext(i18n)` in a root component runs `init()` for you, in step 5.

**Solid**

     The React file with the imports from `@comvi/solid`. `useI18n()` reads Solid context,
     so provide the instance above every component that calls it, in step 5.

**Vanilla**

     The React file, with `createI18n` imported from `@comvi/core` and `icuCompiler` from
     `@comvi/core/icu`. Everything else is identical.

   One i18n instance, one `.use(installer)` per capability: `localeDetector` brings the
   plugin capability, `cdnLoader` brings the loader and the plugin capability. With
   `caches: ['cookie']`, Comvi's detector defaults to `cacheFirst: true`, which reads the
   first cache target before it walks `order`, so a cookie from a previous visit beats
   the `?lng=` query parameter. `cacheFirst: false` lets `order` govern priority, as
   i18next's `detection` did; leave the default when a persisted choice should win over
   everything. Comvi's cookie is `i18n_lang` by default, so `lookupCookie: 'i18next'`
   keeps the choice returning visitors already made.

   The order of the `.use(…)` calls does not matter: the detector runs inside `init()`,
   after every plugin and before the initial namespaces load, so the loader always sees
   the detected locale.

3. **Update translation calls**

   `src/greet.ts`

   ```ts
   import { i18n } from './i18n';

   // i18next catalog: "Hello, {{name}}!"  →  Comvi catalog: "Hello, {name}!"
   i18n.t('welcome', { name: 'Alice' });

   // Namespaced lookups are identical in both libraries.
   i18n.t('overview', { ns: 'dashboard' });
   ```

   `t()` is unchanged; the interpolation braces are not. The i18next prefix form
   `t('dashboard:overview')` is not parsed: it renders the key as is and fails type
   checking, so rewrite it with `{ ns: 'dashboard' }`.

4. **Replace the plugins**

   | i18next plugin                     | Comvi                                                          |
   | ---------------------------------- | ---------------------------------------------------------------- |
   | `i18next-http-backend`             | `@comvi/plugin-fetch-loader` (`cdnLoader`; `fetchLoader` from `/api`) |
   | `i18next-http-backend` on your own files | `.use(attachLoader)`, then `i18n.registerLoader((locale, ns) => fetch(…).then((r) => r.json()))` |
   | `i18next-browser-languagedetector` | `@comvi/plugin-locale-detector` (`localeDetector`)              |
   | `i18next-localstorage-backend`     | no equivalent; bundle offline catalogs through the loader's `fallback` |
   | `i18next-chained-backend`          | not needed; `fallback` on the loader covers the offline path    |
   | `initReactI18next`                 | not needed; the binding reads the instance you pass to its provider |

   Each first-party package exports an installer for `.use(…)` and a `Plugin`-suffixed
   factory for `registerPlugin(…)` on an instance that already has the plugin capability.
   Writing your own is on [Write a plugin](/docs/i18n/plugins/custom-plugins).

5. **Update the framework binding**

   `src/MyComponent.before.tsx`

   ```tsx
   // Before (react-i18next)
   import { useTranslation, Trans } from 'react-i18next';

   export function MyComponent() {
     const { t } = useTranslation();
     return (
       <>
         <h1>{t('title')}</h1>

           Read our <a href="/terms">terms of service</a>

       </>
     );
   }
   ```

   `src/MyComponent.tsx`

   ```tsx
   // After (Comvi), catalog: "Read our <link>terms of service</link>"
   import { useI18n, T } from '@comvi/react';

   export function MyComponent() {
     const { t } = useI18n();
     return (
       <>
         <h1>{t('title')}</h1>
         <T i18nKey="legal.tos" components={{ link: <a href="/terms" /> }} />
       </>
     );
   }
   ```

   `useTranslation()` becomes `useI18n()`. `<Trans>` took the fallback markup from its
   children; `<T>` takes the tags from the message and maps them through `components`, so
   the English copy moves into the catalog. Put `<I18nProvider i18n={i18n}>` above the
   tree. Per framework:

   - **Next.js:** `useI18n` from `@comvi/next/client` in `'use client'` components, and
     `const { t } = await getI18n({ locale })` from `@comvi/next/server` in a Server Component.
     `getI18n()` reads the instance from the module `withComvi` names. The `[locale]` layout
     renders `<ComviProvider locale>` from `@comvi/next/server`. See [Next.js](/docs/i18n/next).
   - **Vue:** `useTranslation()` from `i18next-vue` becomes `useI18n()` from `@comvi/vue`.
   - **Nuxt:** register the module in `nuxt.config.ts` as in step 2; `useI18n` is
     auto-imported, so no import line at all.
   - **Svelte:** a root component calls `setI18nContext(i18n)`, then `useI18n()` in any
     descendant. `t` is a store, so the markup reads `{$t('title')}`. `useI18n()` is
     callable during component initialisation only and throws when no ancestor called
     `setI18nContext`.
   - **Solid:** wrap the tree in `<I18nProvider i18n={i18n}>` from `@comvi/solid`;
     `useI18n()` reads context and throws without it. Nothing about Comvi is global.
   - **Vanilla:** `i18next.t('title')` becomes `i18n.t('title')` on the instance you exported.

6. **Migrate the translation files**

   The layout carries over: one JSON per namespace per locale, nested keys flattened with dots.

   | Value            | i18next                                       | Comvi                                                                    |
   | ---------------- | --------------------------------------------- | ------------------------------------------------------------------------ |
   | Interpolation    | `"Hello, {{name}}!"`                          | `"Hello, {name}!"`                                                       |
   | Plurals          | `items_zero`, `items_one`, `items_other` keys | `"items": "{count, plural, =0 {No items} one {# item} other {# items}}"` |
   | Context          | `key_male` / `key_female` keys                | `"{gender, select, male {He} female {She} other {They}} left a review"`  |
   | Nesting          | `"Welcome! $t(greeting)"`                     | `"Welcome! {greeting}"`, called as `t('welcome', { greeting: t('greeting') })` |
   | `<Trans>` tags   | `"Read our <1>terms</1>"` (child index)       | `"Read our <link>terms</link>"`; named tags from `<Trans components>` carry over |

   The brace change is a search and replace. The plural change is not: three suffix keys
   collapse into one message, the count moves inside it as `#`, and every call site that
   passed `count` keeps working. A context call changes: `{ context: 'female' }` becomes
   the select's own parameter, `{ gender: 'female' }`. Then import each locale's JSON in
   your project on [app.comvi.io](https://app.comvi.io).

7. **Turn on the type checking**

   `src/types.ts`

   ```ts
   import './types/i18n';
   ```

   `npx comvi typegen` fetches the schema from the Comvi platform and writes a `.d.ts`
   ([comvi typegen](/docs/cli/typegen)); `comviTypes({ translations })` in
   `vite.config.ts` regenerates from local JSON on every change, and reads the i18next
   folder layout with `fileTemplate: '{languageTag}/{namespace}.json'` and `defaultNs:
   'common'` ([Vite plugin](/docs/i18n/plugins/vite-plugin)). Both use the same emitter,
   so CI can run one and dev the other. Import the generated file once, for its side
   effect. From then on an unknown key, a missing parameter and a wrong parameter type
   are compile errors in every binding. Add `comvi typegen --check` to CI.

8. **Remove i18next**

   `Terminal`

   ```bash
   grep -r "i18next" src/ --include="*.ts" --include="*.tsx" --include="*.vue"
   ```

   Then drop every `i18next*` package, any `i18next.config.js` / `.i18nrc`, and the
   `package.json` scripts that called i18next CLI tools.

## Syntax comparison reference

| Pattern              | i18next                            | Comvi                                                            |
| -------------------- | ---------------------------------- | ---------------------------------------------------------------------- |
| Interpolation        | `Hello, {{name}}!`                 | `Hello, {name}!`                                                       |
| Plural               | `key_one` / `key_other` keys       | `{count, plural, one {# item} other {# items}}`                        |
| Ordinal              | `key_ordinal_one` keys             | `{pos, selectordinal, one {#st} two {#nd} few {#rd} other {#th}}`      |
| Context              | `key_male` / `key_female` keys     | `{gender, select, male {…} female {…} other {…}}`                      |
| Nesting              | `$t(otherKey)`                     | pass a parameter, or make two `t()` calls                              |
| Number format        | `{{val, number}}`                  | `formatNumber(val)` from `useI18n()`, or `formatNumber(i18n, val)` from `@comvi/core`; `{val}` in the message |
| Date format          | `{{val, datetime}}`                | `formatDate(val, options)` from `useI18n()`, or `formatDate(i18n, val, options)`; `{val}` in the message |
| Escaping a brace     | not needed                         | wrap it in single quotes: `'{'`                                        |
| Rich text            | `<Trans>` with children as source  | `<T>` with the tags in the message and `components` for the elements   |

## API mapping reference

| i18next                                | Comvi                                                            |
| -------------------------------------- | ---------------------------------------------------------------------- |
| `i18next.changeLanguage(lng)`          | `i18n.setLocale(locale)`, a promise that resolves after the load |
| `i18next.language`                     | `i18n.locale`                                                         |
| `t(key, { defaultValue: 'Hi' })`       | `t(key, { fallback: 'Hi' })`                                          |
| `i18next.exists(key)`                  | `i18n.hasTranslation(key)`. On `@comvi/vue` / `@comvi/nuxt` that returns a `ComputedRef` and is therefore always truthy in an `if`, so the boolean form is `hasTranslationNow(key)` on the **instance** (`inject(I18N_INJECTION_KEY)!` in Vue, `useNuxtApp().$i18n` in Nuxt), not on `useI18n()` |
| `i18next.on('languageChanged', fn)`    | `i18n.on('localeChanged', fn)`, payload `{ from, to }`                |
| `useTranslation('dashboard')`          | `useI18n('dashboard')`, which binds `t` but loads nothing: list the namespace in `ns`, or call `addActiveNamespace('dashboard')` from `useI18nLoader()` |
| `missingKeyHandler` (with `saveMissing`) | `i18n.on('missingKey', fn)`, payload `{ key, locale, namespace }`   |
| `parseMissingKeyHandler`               | the `onMissingKey` constructor option, or `useI18nPlugins()` at runtime; its return value replaces the key |
| `i18next.use(Backend)`                 | `.use(cdnLoader({ … }))`, or `.use(fetchLoader({ … }))` from `/api`  |
| `i18next.use(LanguageDetector)`        | `.use(localeDetector({ … }))`                                        |

`fallback` applies once the whole locale chain has missed and then outranks every
`onMissingKey` handler: plugin callbacks still run to record the miss, but their result is
discarded and the constructor option is never consulted. The string is interpolated with the
same params, so `t('greeting', { name: 'Alice', fallback: 'Hi, {name}!' })` renders `Hi, Alice!`.

## Migration checklist

- [ ] Install the binding, `@comvi/plugin-fetch-loader`, and `@comvi/plugin-locale-detector`
      if you used the i18next detector
- [ ] Replace `i18next.use(…).init()` with your framework's constructor plus one
      `.use(installer)` per capability
- [ ] Set `cacheFirst: false` on the detector if `order` is meant to outrank a persisted choice,
      and `lookupCookie: 'i18next'` to keep the cookie i18next wrote
- [ ] Decide the compiler: `compiler: icuCompiler`, or nothing if you use no plurals
- [ ] Update `useTranslation()` to `useI18n()`, and every `t('ns:key')` to `t('key', { ns })`
- [ ] Replace `changeLanguage(lng)` with `setLocale(locale)`, and `await` it: the bare
      `i18n.locale = 'de'` setter is fire-and-forget and reports failure as a `loadError`
      event instead of a rejection
- [ ] Rename `defaultValue` to `fallback`, `exists()` to `hasTranslation()` (on Vue and Nuxt,
      `hasTranslationNow()` on the instance), and `languageChanged` to `localeChanged`
- [ ] Wrap the tree in the binding's provider where it has one
- [ ] Replace `<Trans>` with `<T>`, moving the copy from children into the catalog
- [ ] In every value: `{{name}}` to `{name}`, suffix keys to ICU `plural` / `select`,
      `{{val, number}}` / `{{val, datetime}}` to a formatter call in code, no `$t(key)` nesting
- [ ] Import the catalogs into the Comvi platform
- [ ] Generate the types with `comvi typegen`, or with `comviTypes()` in `vite.config.ts`,
      and import the generated `.d.ts` once
- [ ] Uninstall every `i18next*` package and its config files
- [ ] Run the app and check that no key renders as its own name

## Read next

- [How an i18n instance is built](/docs/i18n/api/core#useinstaller): what the base instance has, and what `.use(installer)` adds.
- [Pluralize with ICU](/docs/i18n/pluralization): which compiler you have, and exactly what ICU implements.
- [Fetch loader](/docs/i18n/plugins/fetch-loader): CDN mode, API mode, and offline fallbacks.
