# Migrate from next-intl

Switch from next-intl to @comvi/next: createNextI18n, withComvi in next.config, ComviProvider in the layout, getI18n({ locale }) in Server Components, navigation, and which ICU syntax carries over.

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

Move a Next.js App Router app from next-intl to `@comvi/next`, where the architecture lines
up: a `next.config` plugin points at one config module, the locale layout renders a provider,
and Server Components call an async helper. Most of the migration is renaming; what differs
is the meaning of the namespace argument and the default locale prefix.

## Key differences

| Feature            | next-intl                                    | `@comvi/next`                                                            |
| ------------------ | -------------------------------------------- | ------------------------------------------------------------------------ |
| Setup              | `i18n/request.ts` with `getRequestConfig`    | `i18n/config.ts` with `createNextI18n({ locales, defaultLocale })`       |
| `next.config`      | `createNextIntlPlugin('./src/i18n/request.ts')` | `withComvi({ config: './src/i18n/config.ts' })` from `@comvi/next/plugin` |
| Layout provider    | `<NextIntlClientProvider>`                   | `<ComviProvider locale>` from `@comvi/next/server`                       |
| Server translation | `getTranslations()`, or `useTranslations()` in a Server Component | `await getI18n({ locale })` from `@comvi/next/server`  |
| Client translation | `useTranslations()`                          | `useI18n()` from `@comvi/next/client`                                    |
| Middleware         | `createMiddleware(routing)`                  | `createMiddleware(routing)` from `@comvi/next/middleware`                |
| Locale prefix      | `'always'` by default                        | `'as-needed'` by default; set `localePrefix: 'always'` to keep your URLs |
| Navigation         | `createNavigation(routing)`                  | `Link`, `usePathname`, `useLocalizedRouter` from `@comvi/next/navigation` |
| Rich text          | `t.rich('key', { tag: (chunks) => … })`      | `<T i18nKey components={{ tag: <el /> }} />`                             |
| Plurals / select   | ICU                                          | the same ICU, with `compiler: icuCompiler` named in the options          |
| Number / date ICU  | `{v, number}`, `{d, date, long}`             | not implemented; format in code and interpolate the result               |
| Translation source | JSON in `messages/`                          | anything you register a loader for, including the Comvi CDN             |

## Migration steps

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

     ```bash
     npm install @comvi/next @comvi/plugin-fetch-loader
     npm uninstall next-intl
     ```

**pnpm**

     ```bash
     pnpm add @comvi/next @comvi/plugin-fetch-loader
     pnpm remove next-intl
     ```

**yarn**

     ```bash
     yarn add @comvi/next @comvi/plugin-fetch-loader
     yarn remove next-intl
     ```

**bun**

     ```bash
     bun add @comvi/next @comvi/plugin-fetch-loader
     bun remove next-intl
     ```

2. **Replace the request config with `createNextI18n`**

   `src/i18n/request.ts (next-intl)`

   ```ts
   import { getRequestConfig } from 'next-intl/server';
   import { hasLocale } from 'next-intl';
   import { routing } from './routing';

   export default getRequestConfig(async ({ requestLocale }) => {
     const requested = await requestLocale;
     const locale = hasLocale(routing.locales, requested) ? requested : routing.defaultLocale;
     return { locale, messages: (await import(`../../messages/${locale}.json`)).default };
   });
   ```

   `src/i18n/config.ts`

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

   export const { i18n, routing } = createNextI18n({
     locales: ['en', 'de', 'fr'],
     defaultLocale: 'en',
     localePrefix: 'always',
     fallbackLocale: 'en',
     compiler: icuCompiler,
   }).use(cdnLoader({ cdnUrl: 'https://cdn.comvi.io/<project-token>' }));
   ```

   One call builds the i18n instance and the routing config, so the options of next-intl's
   `defineRouting` move here. `localePrefix: 'always'` keeps next-intl's URLs; Comvi's default,
   `'as-needed'`, serves the default locale from `/` and redirects `/en` there. ICU is the
   `compiler` option, because next-intl's messages use `plural` and `select` and no constructor
   turns ICU on by itself. `.use()` takes the installer `cdnLoader`; the raw `cdnLoaderPlugin`
   goes to `registerPlugin()`, and `.use()` throws `E_USE_INSTANCE_IDENTITY` on it.

   `fetchLoader` from `@comvi/plugin-fetch-loader/api` also carries the authenticated API path,
   so a CDN-only app pays for code it never reaches
   ([the two loader entries](/docs/i18n/bundle-size#the-two-loader-entries)). Use it only on
   the server, because the `apiKey` must not enter a client bundle.

   To stay with bundled JSON, write `.use(loader({ en: () => import('../../messages/en.json'), … }))`
   with `loader` from `@comvi/next`. Nested JSON loads as it is: `{ "home": { "title": … } }`
   is the key `home.title`. A plugin scoped to one runtime goes through
   `nextPlugin(plugin, { runtime, environment })` or `nextPluginLazy(load, { runtime })`.

3. **Point `next.config` at the config module**

   `next.config.ts (next-intl)`

   ```ts
   import createNextIntlPlugin from 'next-intl/plugin';

   const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts');

   export default withNextIntl({});
   ```

   `next.config.ts`

   ```ts
   import { withComvi } from '@comvi/next/plugin';

   export default withComvi({ config: './src/i18n/config.ts' })({});
   ```

   `withComvi` points the server helpers and the layout provider at the config module's `i18n`
   and `routing` exports, under webpack and Turbopack, with no registration call. The module
   runs in the client build as well, so a loader that needs server-only code stays on the
   [manual setup](/docs/i18n/guides/next-server-client#manual-setup).

4. **Update the middleware**

   `src/middleware.ts (next-intl)`

   ```ts
   import createMiddleware from 'next-intl/middleware';
   import { routing } from './i18n/routing';

   export default createMiddleware(routing);
   export const config = { matcher: ['/((?!api|_next|.*\\..*).*)'] };
   ```

   `src/middleware.ts`

   ```ts
   import { createMiddleware } from '@comvi/next/middleware';
   import { routing } from './i18n/config';

   export default createMiddleware(routing);

   export const config = { matcher: ['/((?!api|_next|.*\\..*).*)'] };
   ```

   The middleware reads the locale from the URL prefix, then the cookie, then
   `Accept-Language`, and redirects to the prefixed URL. next-intl's `localeCookie` goes into
   the `createNextI18n` options and `localeDetection` onto the middleware,
   `createMiddleware({ ...routing, localeDetection: false })`. The cookie is still
   `NEXT_LOCALE`; it lasts one year and is `Secure` in production, not a session cookie.

5. **Update the locale layout**

   `src/app/[locale]/layout.tsx (next-intl)`

   ```tsx
   import { notFound } from 'next/navigation';
   import { hasLocale, NextIntlClientProvider } from 'next-intl';
   import { routing } from '../../i18n/routing';

   export default async function LocaleLayout({ children, params }) {
     const { locale } = await params;
     if (!hasLocale(routing.locales, locale)) notFound();

     return (
       <html lang={locale}>
         <body>
           <NextIntlClientProvider>{children}</NextIntlClientProvider>
         </body>
       </html>
     );
   }
   ```

   `src/app/[locale]/layout.tsx`

   ```tsx
   import { notFound } from 'next/navigation';
   import { hasLocale } from '@comvi/next/routing';
   import { ComviProvider } from '@comvi/next/server';
   import { routing } from '../../i18n/config';

   export default async function LocaleLayout({
     children,
     params,
   }: {
     children: React.ReactNode;
     params: Promise<{ locale: string }>;
   }) {
     const { locale } = await params;
     if (!hasLocale(routing.locales, locale)) notFound();

     return (
       <html lang={locale}>
         <body>
           <ComviProvider locale={locale}>{children}</ComviProvider>
         </body>
       </html>
     );
   }
   ```

   `<ComviProvider locale>` loads the catalog on the server and carries it across the
   hydration boundary; every client hook and `<T>` reads its context. The client applies the
   catalog before its first commit, so there is no flash of untranslated content. `hasLocale`
   moves from `next-intl` to `@comvi/next/routing` with the same signature.

6. **Update Server Component translations**

   `src/app/[locale]/page.tsx (next-intl)`

   ```tsx
   import { useTranslations } from 'next-intl';

   export default function HomePage() {
     const t = useTranslations('home');

     return (
       <main>
         <h1>{t('title')}</h1>
         <p>{t('description')}</p>
       </main>
     );
   }
   ```

   `src/app/[locale]/page.tsx`

   ```tsx
   import { getI18n } from '@comvi/next/server';

   export default async function HomePage({
     params,
   }: {
     params: Promise<{ locale: string }>;
   }) {
     const { locale } = await params;
     const { t } = await getI18n({ locale });

     return (
       <main>
         <h1>{t('home.title')}</h1>
         <p>{t('home.description')}</p>
       </main>
     );
   }
   ```

   `getI18n({ locale })` returns `{ t, hasTranslation, locale }`, with no formatters. Every lookup names the locale from the `[locale]` param, so two concurrent
   renders at different locales cannot race. Where next-intl scopes with
   `useTranslations('home')`, write the prefix into the key, `t('home.title')`:
   `getI18n({ ns: 'home' })` names a separate catalog, not a prefix.

7. **Update Client Component translations**

   `src/components/Counter.tsx`

   ```tsx
   'use client';
   import { useI18n } from '@comvi/next/client';

   export function Counter() {
     const { t } = useI18n();
     return <button>{t('counter.increment')}</button>;
   }
   ```

   `useTranslations()` becomes `useI18n()`; the loader and plugin members are on `useI18nLoader()`
   and `useI18nPlugins()`.

   > **`useI18n('home')` is not `useTranslations('home')`**
>
> next-intl's argument is a key prefix inside one flat message tree: `useTranslations('home')`
> then `t('title')` resolves `home.title`.
> 
> Comvi's argument is a namespace: a separate catalog, loaded separately. `useI18n('home')`
> then `t('title')` looks for `title` in a catalog named `home`, and if nothing loaded that
> namespace, the key misses. A straight port compiles and renders keys instead of text. Two
> correct ports:
> 
> - **Keep the prefix a prefix.** If `home.title` is a key in your default namespace, drop
> the argument and use the full key: `useI18n()` then `t('home.title')`.
> - **Make it a real namespace.** Split `home` into its own catalog and load it, with
> `ns: ['default', 'home']` on `createNextI18n` or with `addActiveNamespace('home')`.
> 
> Once you generate types, `useI18n('home').t('title')` is checked against the key
> `home:title`, so a prefix ported as a namespace is a compile error. See
> [comvi typegen](/docs/cli/typegen#use-the-generated-file).

8. **Update rich text**

   `src/components/LegalNotice.tsx (next-intl)`

   ```tsx
   import { useTranslations } from 'next-intl';

   export function LegalNotice() {
     const t = useTranslations();
     // Translation: "Read our <link>terms</link> and <bold>privacy policy</bold>"
     return t.rich('legal.notice', {
       link: (chunks) => <a href="/terms">{chunks}</a>,
       bold: (chunks) => <strong>{chunks}</strong>,
     });
   }
   ```

   `src/components/LegalNotice.tsx`

   ```tsx
   'use client';
   import { T } from '@comvi/next/client';

   // Translation: "Read our <link>terms</link> and <bold>privacy policy</bold>"
   export function LegalNotice() {
     return (
       <T
         i18nKey="legal.notice"
         components={{ link: <a href="/terms" />, bold: <strong /> }}
       />
     );
   }
   ```

   `src/Callout.tsx`

   ```tsx
   'use client';
   // next-intl: t.rich('legal.notice', { bold: (chunks) => <b className="hi">{chunks}</b> })
   import { T } from '@comvi/next/client';

   export function Callout() {
     return (
       <T
         i18nKey="legal.notice"
         components={{ bold: ({ children }) => <b className="hi">{children}</b> }}
       />
     );
   }
   ```

   `components` takes an element, which is cloned with the tag's children, a bare HTML tag
   name, or a render function `({ children }) => element`. A `t.rich` handler takes
   `{ children }` where it took `chunks`; the messages stay as they are, because next-intl
   writes rich text as `<tag>…</tag>` too. `<T>` is a Client Component, so a file that hands it
   a render function needs `'use client'`. Plain `t()` returns the text with the tags removed.
   `<T>` renders its `fallback` prop or its children when the key is missing.

9. **Update navigation**

   `src/components/Nav.tsx`

   ```tsx
   'use client';
   import { Link } from '@comvi/next/navigation';

   // Prefixes the current locale automatically.
   export function Nav() {
     return <Link href="/about">About</Link>;
   }
   ```

   `createNavigation(routing)`'s `Link`, `usePathname` and `useRouter` become `Link`,
   `usePathname` and `useLocalizedRouter` from `@comvi/next/navigation`; the router takes the
   locale as an argument, `push('/about', 'de')`. `getPathname` is `createGetPathname(routing)`
   from `@comvi/next/routing`, and its result goes to Next's own `redirect`.

10. **Migrate the message files**

    Plurals, ordinals, `select` and quoted braces carry over unchanged with
    `compiler: icuCompiler`. The `number` / `date` / `time` argument types and the `offset:`
    plural form are not implemented: development throws `E_ICU_SYNTAX`, and production renders
    the message source and reports it. Format those values in code and pass a plain `{param}`.
    See [What ICU does not implement](/docs/i18n/icu-reference#what-icu-does-not-implement).

    Then import each locale's JSON in your project on
    [app.comvi.io](https://app.comvi.io) and delete `messages/`.

11. **Remove next-intl**

    ```bash
    grep -r "next-intl" src/ --include="*.ts" --include="*.tsx"
    ```

    Then drop `next-intl`, `src/i18n/request.ts`, `src/i18n/routing.ts`,
    `src/i18n/navigation.ts` and the `messages/` directory.

## API mapping reference

| next-intl                       | Comvi                                                   | Notes                                                    |
| ------------------------------- | ------------------------------------------------------- | -------------------------------------------------------- |
| `getRequestConfig`              | `createNextI18n({ … })` in `i18n/config.ts`             | returns `{ i18n, routing }` plus `.use()`                |
| `defineRouting({ … })`          | the same options in `createNextI18n({ … })`             | `localePrefix` defaults to `'as-needed'`, not `'always'` |
| `createNextIntlPlugin(path)`    | `withComvi({ config: path })`                           | from `@comvi/next/plugin`; the path is the config module |
| `NextIntlClientProvider`        | `<ComviProvider locale>`                                | from `@comvi/next/server`; loads and ships the catalog   |
| `getTranslations({ locale })`   | `await getI18n({ locale })`                             | async; returns `{ t, hasTranslation, locale }`           |
| `setRequestLocale(locale)`      | `setRequestLocale(locale)` from `@comvi/next/server`    | not needed when every page calls `getI18n({ locale })`   |
| `useTranslations()` (client)    | `useI18n()`                                             | returns `{ t, tRaw, locale, isLoading, formatNumber, formatDate, … }` |
| `useTranslations('ns')`         | `useI18n('ns')` / `getI18n({ ns })`                     | not equivalent: a key prefix versus a separate catalog   |
| `t.rich('key', { tag: fn })`    | `<T i18nKey="key" components={{ tag: fn }} />`          | element, tag name, or `({ children }) => element`        |
| `useFormatter()`                | `useI18n().formatNumber` / `formatDate`                 | client only; on the server, use `Intl` directly          |
| `useLocale()`                   | `useLocale()` from `@comvi/next/client`                 | or `useI18n().locale`                                    |
| `createMiddleware(routing)`     | `createMiddleware(routing)`                             | from `@comvi/next/middleware`                            |
| `localeCookie` in `defineRouting` | `localeCookie` in `createNextI18n`                    | a name, `{ name, maxAge, secure, sameSite, domain }` or `false` |
| `localeDetection: false` in `defineRouting` | `createMiddleware({ ...routing, localeDetection: false })` | an object sets `order`, `queryParam`, `headerName`, `resolveAcceptLanguage` |
| `createNavigation(routing)`     | `Link` / `usePathname` / `useLocalizedRouter`           | from `@comvi/next/navigation`; `push(href, locale)`      |
| `getPathname` / `redirect`      | `createGetPathname(routing)` from `@comvi/next/routing` | no localized `redirect`; pass the path to Next's         |
| `useNow()` / `useTimeZone()`    | `Date.now()` / `Intl.DateTimeFormat`                    | not an i18n concern                                      |
| `{v, number}` in a message      | `formatNumber(v)` in code, `{v}` in the message         | ICU argument types are not implemented                   |

## Read next

- [Next.js: server and client](/docs/i18n/guides/next-server-client): the request contract, the provider props and plugin scoping.
- [Render on the server](/docs/i18n/ssr): what a shared instance may and may not carry.
- [Type-check translation keys](/docs/i18n/type-safety): generated `TranslationKeys` for every `t()` call.
