Skip to content
Comvi

Next.js

View .md

With a Next.js 15 or 16 App Router app, @comvi/next is one config module that builds the i18n instance, a next.config plugin that points the library at it, and a provider in the locale layout, so t() works in Server and Client Components alike. The files are the ones the Quick start creates.

Open the Next.js example in StackBlitz to see server rendering, the locale in the URL (/de), plurals and <T> with a link inside the text running.

Terminal window
npm install @comvi/next

@comvi/next peer-depends on next (^15 or ^16) and react (^18.2 or ^19) and brings @comvi/core and @comvi/react.

next.config.ts
import { withComvi } from '@comvi/next/plugin';
export default withComvi({ config: './src/i18n/config.ts' })({});

withComvi({ config }) points the library at the module that exports i18n and routing, under webpack and Turbopack alike. Wrap your existing options in place of the empty object; a path that does not resolve fails the build with the resolved path in the message.

src/i18n/routing.ts
import { defineRouting } from '@comvi/next/routing';
export const routing = defineRouting({ locales: ['en', 'uk'], defaultLocale: 'en' });

routing.ts holds the URL rules and nothing else, so src/middleware.ts from the Quick start can import it without pulling the instance into the Edge runtime; the prefix modes and the detection order are on Locale routing.

src/i18n/config.ts
import { createNextI18n } from '@comvi/next';
import { routing } from './routing';
export const { i18n } = createNextI18n({
...routing,
messages: { en: { greeting: 'Hello, {name}!' }, uk: { greeting: 'Привіт, {name}!' } },
});
export { routing };

config.ts spreads those rules into createNextI18n(), which builds the instance with defaultLocale as its locale and its fallback. The server build and the client build both bundle this module, and each evaluates it once, so everything in it must be safe to run in the browser.

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/routing';
type Props = { children: React.ReactNode; params: Promise<{ locale: string }> };
export default async function LocaleLayout({ children, params }: Props) {
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 locale and every locale in its fallback chain on the server, hands the result to a "use client" provider the library ships, and sets the request locale for the Server Components under it. hasLocale plus notFound() and <html lang> stay yours: an unknown segment is a 404 only if you say so. Every helper is on Server and client APIs.

src/app/[locale]/page.tsx
import { getI18n } from '@comvi/next/server';
import { Greeting } from './Greeting';
export default async function HomePage({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
const { t } = await getI18n({ locale });
return <div><h1>{t('greeting', { name: 'Server' })}</h1><Greeting /></div>;
}
src/app/[locale]/Greeting.tsx
'use client';
import { useI18n } from '@comvi/next/client';
export function Greeting() {
const { t, locale } = useI18n();
return <p>{t('greeting', { name: 'Client' })} ({locale})</p>;
}

In a Server Component getI18n({ locale }) returns t, hasTranslation and locale, bound to the locale you pass, and t() returns a plain string. Pass the locale: a page can render before its layout finishes, and the explicit form reads nothing from the request. The locale travels inside each call’s params, so two requests rendering at once cannot race on it. In a Client Component useI18n() and the other React hooks come from @comvi/next/client.

<T i18nKey="legal.tos" components={{ link: <a href="/terms" /> }} /> maps each tag a translator wrote, here Read our <link>Terms of Service</link>, to a React element. <T> comes from @comvi/next/client, so it renders in Client Components; a Server Component’s t() returns plain text, with each tag replaced by its inner text. The props are on Tag interpolation.

src/i18n/config.ts
import { createNextI18n } from '@comvi/next';
import { cdnLoader } from '@comvi/plugin-fetch-loader';
import { routing } from './routing';
export const { i18n } = createNextI18n({ ...routing }).use(
cdnLoader({ cdnUrl: 'https://cdn.comvi.io/<project-token>' }),
);
export { routing };

Install @comvi/plugin-fetch-loader and replace the inline catalog with the loader; every other file stays as it is. On the server, <ComviProvider> fetches <cdnUrl>/<locale>.json the first time a locale:namespace is needed and keeps it in the instance’s cache for the life of the process. The browser never fetches on first load, because the catalog arrives with the provider; a later setLocale() to a locale that was not shipped fetches it from the CDN. The rest of the walkthrough is on Connect to Comvi.

The CDN endpoint URL is under Settings → Content Deploy in your Comvi project, once Content Deploy is enabled and saved; pass it as cdnUrl. The URL format is on CDN Deployment.

If your keys use ICU ({count, plural, …}), add compiler: icuCompiler as on Pluralize with ICU. The platform and the compiler are independent: either works without the other.

<ComviProvider> loads on the server and seeds the client with the result, so the HTML already carries translated text and the first paint never waits on the CDN. The client side passes ssrInitialIsInitializing={false} to the React provider for you; a fallback prop would be a post-hydration gate only, and with the catalog already there, nothing is left for it to hide.

Server Components translate through getI18n({ locale }). Client Components render on the server too, seeded from the catalog the provider loaded and from the locale prop, which reaches the tree as ssrLocale without writing to the instance. The instance lives once per process; the locale belongs to the request, which is why the server helpers take it as an argument. See Render on the server.

src/i18n/config.ts is bundled into the server build and into the client build, and each evaluates it once, so there are two instances, one per runtime, built from the same expression. The catalog travels between them as plain data; the composition does not travel, it runs again, which is why the client instance has the same loader and compiler with no extra code. Keep one config module, client-safe, and let withComvi import it from both sides. A loader that needs server-only code cannot live there; that app keeps a manual setup, on Server and client APIs.

withComvi adds one resolve alias to the webpack and Turbopack configs: the internal specifier @comvi/next/config becomes your src/i18n/config.ts. The server helpers and the library’s client provider import that specifier, so in the server graph they read the i18n your module exports, and in the client graph they read the client copy of the same module. No instance is registered at runtime and nothing crosses the RSC boundary but data.

A request lands on /uk. The layout renders <ComviProvider locale="uk">, which stores the locale in a React cache() slot that exists for this request alone, then loads the default namespace for uk and for every locale in the fallback chain. getI18n({ locale: 'uk' }) in the page skips that slot: it runs init() once per process (concurrent callers share the same promise), checks the cache for uk:default and its fallbacks, loads what is missing, and binds t with the locale inside every call. Without an argument, getI18n() reads the slot the provider filled, or the x-comvi-locale header the middleware sets, and throws when it finds neither. A header value outside routing.locales is ignored and the request renders in routing.defaultLocale.

What the provider hands to the client is the loaded catalogs as plain objects keyed locale:namespace, because the Server-to-Client serializer rejects null-prototype objects. With no loader composed it ships whatever the inline catalog put in the cache and warns once when that is empty. Shipping the fallback catalogs is what keeps a key the server rendered from a fallback from showing up as a raw key after hydration.

Nothing writes the instance during a render, on either side. The provider hands locale and messages to the React provider as ssrLocale, targetLocale and messages, with ssrInitialIsInitializing={false}: the render translates from a read view of the handed-over catalog, in the handed-over locale, so the server snapshot and the hydrating render read the same text and the same “ready” flag, and the children render on both sides with no mismatch. When that render commits, two layout effects run in order: the React provider’s adds messages to the client instance’s cache, then @comvi/next’s moves i18n.locale to the route’s locale, which finds the catalog cached and loads nothing. A render React abandons therefore leaves the instance as it was, and a server render never writes the shared instance at all: it validates the locale, reports one outside routing.locales, and renders. The effect that follows runs init() on the client instance; the catalogs it would load came with messages, so nothing is fetched.

A client-side navigation from /en to /uk re-renders the layout with a new locale prop. The provider’s layout effect re-applies the locale and adds the new catalogs, localeChanged fires, and every useI18n() consumer re-renders with the new text. A missing key behaves the same in both runtimes: t() returns the key and the instance emits missingKey.

what happened message
<ComviProvider> rendered with no withComvi in next.config [@comvi/next] <ComviProvider> needs withComvi({ config }) in next.config: the client reads the i18n instance from that module. Add it, or keep your own "use client" wrapper around <I18nProvider> from @comvi/next/client (the manual setup, also the one for a config whose loader needs server-only code).
getI18n() with nothing configured opens with [@comvi/next] i18n not configured. Add withComvi({ config }) to next.config, or call setI18n(i18n) / createNextI18nFromInstance(factory, routing)., then names the test-runner alias and serverExternalPackages; the full text is on Error reference
getI18n() without a locale, outside <ComviProvider>, on a route the middleware did not see [@comvi/next] Locale not set. Call setRequestLocale(locale) in your layout/page first, or configure middleware.
a server helper needs a namespace and the instance has no loader [@comvi/next] No loader registered, so SSR loaded nothing. Register one via i18n.registerLoader(...), or compose one on the createNextI18n(...) result: .use(cdnLoader({ cdnUrl })) or .use(loader(map))., a warning once per instance; the render continues from the cache
getI18n({ locale }) with a locale routing.locales does not list development warning once per locale, naming the list; the page renders from the fallback chain

More messages and their fixes are on Troubleshooting.