Next.js
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.
Install
Section titled “Install”npm install @comvi/nextpnpm add @comvi/nextyarn add @comvi/nextbun add @comvi/next@comvi/next peer-depends on next (^15 or ^16) and react (^18.2 or ^19) and brings @comvi/core and @comvi/react.
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.
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.
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.
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.
Use in components
Section titled “Use in components”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>;}'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.
Markup inside a translation
Section titled “Markup inside a translation”<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.
Load from Comvi
Section titled “Load from Comvi”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.
First paint
Section titled “First paint”<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-side rendering
Section titled “Server-side rendering”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.
Two runtimes, one config
Section titled “Two runtimes, one config”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.
How it works
Section titled “How it works”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.
Errors
Section titled “Errors”| 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.
Read next
Section titled “Read next”- Next.js: locale routing: the middleware,
<Link>and prefixed paths. - Connect to Comvi: the CDN URL, and a wording change that ships without a redeploy.
- Next.js API reference: every export of the seven entries.