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
Section titled “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
Section titled “Migration steps”-
Install the packages
Terminal window npm install @comvi/next @comvi/plugin-fetch-loadernpm uninstall next-intlTerminal window pnpm add @comvi/next @comvi/plugin-fetch-loaderpnpm remove next-intlTerminal window yarn add @comvi/next @comvi/plugin-fetch-loaderyarn remove next-intlTerminal window bun add @comvi/next @comvi/plugin-fetch-loaderbun remove next-intl -
Replace the request config with
createNextI18nsrc/i18n/request.ts (next-intl) 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 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
defineRoutingmove here.localePrefix: 'always'keeps next-intl’s URLs; Comvi’s default,'as-needed', serves the default locale from/and redirects/enthere. ICU is thecompileroption, because next-intl’s messages usepluralandselectand no constructor turns ICU on by itself..use()takes the installercdnLoader; the rawcdnLoaderPlugingoes toregisterPlugin(), and.use()throwsE_USE_INSTANCE_IDENTITYon it.fetchLoaderfrom@comvi/plugin-fetch-loader/apialso carries the authenticated API path, so a CDN-only app pays for code it never reaches (the two loader entries). Use it only on the server, because theapiKeymust not enter a client bundle.To stay with bundled JSON, write
.use(loader({ en: () => import('../../messages/en.json'), … }))withloaderfrom@comvi/next. Nested JSON loads as it is:{ "home": { "title": … } }is the keyhome.title. A plugin scoped to one runtime goes throughnextPlugin(plugin, { runtime, environment })ornextPluginLazy(load, { runtime }). -
Point
next.configat the config modulenext.config.ts (next-intl) import createNextIntlPlugin from 'next-intl/plugin';const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts');export default withNextIntl({});next.config.ts import { withComvi } from '@comvi/next/plugin';export default withComvi({ config: './src/i18n/config.ts' })({});withComvipoints the server helpers and the layout provider at the config module’si18nandroutingexports, 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. -
Update the middleware
src/middleware.ts (next-intl) import createMiddleware from 'next-intl/middleware';import { routing } from './i18n/routing';export default createMiddleware(routing);export const config = { matcher: ['/((?!api|_next|.*\\..*).*)'] };src/middleware.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’slocaleCookiegoes into thecreateNextI18noptions andlocaleDetectiononto the middleware,createMiddleware({ ...routing, localeDetection: false }). The cookie is stillNEXT_LOCALE; it lasts one year and isSecurein production, not a session cookie. -
Update the locale layout
src/app/[locale]/layout.tsx (next-intl) 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 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.hasLocalemoves fromnext-intlto@comvi/next/routingwith the same signature. -
Update Server Component translations
src/app/[locale]/page.tsx (next-intl) 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 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 withuseTranslations('home'), write the prefix into the key,t('home.title'):getI18n({ ns: 'home' })names a separate catalog, not a prefix. -
Update Client Component translations
src/components/Counter.tsx 'use client';import { useI18n } from '@comvi/next/client';export function Counter() {const { t } = useI18n();return <button>{t('counter.increment')}</button>;}useTranslations()becomesuseI18n(); the loader and plugin members are onuseI18nLoader()anduseI18nPlugins(). -
Update rich text
src/components/LegalNotice.tsx (next-intl) 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 'use client';import { T } from '@comvi/next/client';// Translation: "Read our <link>terms</link> and <bold>privacy policy</bold>"export function LegalNotice() {return (<Ti18nKey="legal.notice"components={{ link: <a href="/terms" />, bold: <strong /> }}/>);}src/Callout.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 (<Ti18nKey="legal.notice"components={{ bold: ({ children }) => <b className="hi">{children}</b> }}/>);}componentstakes an element, which is cloned with the tag’s children, a bare HTML tag name, or a render function({ children }) => element. At.richhandler takes{ children }where it tookchunks; 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'. Plaint()returns the text with the tags removed.<T>renders itsfallbackprop or its children when the key is missing. -
Update navigation
src/components/Nav.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)’sLink,usePathnameanduseRouterbecomeLink,usePathnameanduseLocalizedRouterfrom@comvi/next/navigation; the router takes the locale as an argument,push('/about', 'de').getPathnameiscreateGetPathname(routing)from@comvi/next/routing, and its result goes to Next’s ownredirect. -
Migrate the message files
Plurals, ordinals,
selectand quoted braces carry over unchanged withcompiler: icuCompiler. Thenumber/date/timeargument types and theoffset:plural form are not implemented: development throwsE_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.Then import each locale’s JSON in your project on app.comvi.io and delete
messages/. -
Remove next-intl
Terminal window grep -r "next-intl" src/ --include="*.ts" --include="*.tsx"Then drop
next-intl,src/i18n/request.ts,src/i18n/routing.ts,src/i18n/navigation.tsand themessages/directory.
API mapping reference
Section titled “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
Section titled “Read next”- Next.js: server and client: the request contract, the provider props and plugin scoping.
- Render on the server: what a shared instance may and may not carry.
- Type-check translation keys: generated
TranslationKeysfor everyt()call.