Skip to content
Comvi

Migrate from next-intl

View .md

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.

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
  1. Install the packages

    Terminal window
    npm install @comvi/next @comvi/plugin-fetch-loader
    npm uninstall next-intl
  2. Replace the request config with createNextI18n

    src/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 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). 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)
    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' })({});

    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.

  4. 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’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)
    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. 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)
    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 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
    '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().

  8. 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 (
    <T
    i18nKey="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 (
    <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
    '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.

    Then import each locale’s JSON in your project on app.comvi.io and delete messages/.

  11. 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.ts and the messages/ directory.

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