Skip to content
Comvi

Next.js: server and client

View .md

With @comvi/next set up as on the Next.js page, translate against the locale on the server, render on the client what the server loaded, keep a server-only loader out of the browser, and scope a plugin to one runtime.

src/app/[locale]/admin/layout.tsx
import { ComviProvider } from '@comvi/next/server';
export default async function AdminLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
return <ComviProvider locale={locale} namespaces={['default', 'admin']}>{children}</ComviProvider>;
}
src/app/[locale]/admin/page.tsx
import { getI18n } from '@comvi/next/server';
import { AdminNav } from './AdminNav';
export default async function AdminPage({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
const { t } = await getI18n({ locale, ns: 'admin' });
return <main><h1>{t('title')}</h1><AdminNav /></main>;
}
src/app/[locale]/admin/AdminNav.tsx
'use client';
import { useI18n } from '@comvi/next/client';
export function AdminNav() {
const { t } = useI18n('admin');
return <nav>{t('nav.users')}</nav>;
}

<ComviProvider namespaces> loads those namespaces for the locale and its fallback chain on the server and ships them to the client as a map keyed "locale:namespace"; the i18n instance never crosses. Only the namespaces in that map are available to a Client Component during hydration: a key from a namespace no provider loaded renders as the key. getI18n({ locale, ns: 'admin' }) binds a namespace on the server and useI18n('admin') on the client.

src/i18n/config.ts
import { createNextI18n, loader } from '@comvi/next';
import { routing } from './routing';
export const { i18n } = createNextI18n({ ...routing }).use(
loader({
en: () => import('../locales/en.json'),
uk: () => import('../locales/uk.json'),
'en:admin': () => import('../locales/admin/en.json'),
}),
);
export { routing };

Map keys are "locale" for the default namespace or "locale:namespace". A colon-less top-level key in messages is a locale, so { en: { default: { greeting } } } flattens to the key default.greeting; write 'en:default' to mean the namespace. For a plain loader function use .use(attachLoader) and registerLoader(fn). In client components useI18nLoader() gives namespace activation, reloadMessages and onLoadError. See Load translations on demand.

  • getI18n({ locale, ns? }) returns t, hasTranslation and locale, bound to the locale you pass. It reads and writes nothing in the request store, so generateMetadata and a page can name different locales in one request. There is no tRaw; rich text is a client concern. A locale routing.locales does not list renders from the fallback chain and warns once in development.
  • getI18n() without a locale reads the request store <ComviProvider> filled, or the x-comvi-locale header the middleware set, and throws [@comvi/next] Locale not set. with neither.
  • getLocale() returns the current request’s locale from the same two sources. It is async because it may read next/headers. A header value outside routing.locales is ignored and routing.defaultLocale is returned.
  • loadMessages(locale, options?) returns the "locale:namespace" map, for a manual <I18nProvider messages> or to preload a namespace getI18n() does not load.
  • <ComviProvider locale namespaces?> sets the request locale, loads the namespaces for the locale and its fallback chain, and renders the library’s "use client" provider with that data. It throws without withComvi in next.config.

The entry also exports core’s createI18n, the installers loader, attachLoader, plugins, devtools, definePlugin and icuCompiler, and the Next-scoped nextPlugin and nextPluginLazy, so a server-side module that composes its own instance names one package. The instance itself never translates on the server: it lives at module scope and is shared by every request, while the locale belongs to the request.

withComvi evaluates config.ts in the client build too, so a loader that imports server-only code (a database client, a file read, an API key) cannot live in it. Such an app skips the plugin and the server <ComviProvider>, and wires the two halves itself.

src/i18n/server.ts
import { createI18n, createNextI18nFromInstance, loader } from '@comvi/next/server';
import { routing } from './routing';
export const { i18n } = createNextI18nFromInstance(
() =>
createI18n({ locale: routing.defaultLocale, defaultNs: 'default' }).use(
loader({ en: () => import('../locales/en.json'), uk: () => import('../locales/uk.json') }),
),
routing,
);
export { routing };
export { getI18n, getLocale, loadMessages, setRequestLocale } from '@comvi/next/server';
src/i18n/client.ts
import { createNextI18n } from '@comvi/next';
import { routing } from './routing';
export const { i18n } = createNextI18n({ ...routing });
export { routing };
src/app/[locale]/ComviProvider.tsx
'use client';
import { I18nProvider, type MessagesMap } from '@comvi/next/client';
import { i18n, routing } from '@/i18n/client';
type Props = { children: React.ReactNode; locale: string; messages?: MessagesMap };
export function ComviProvider({ children, locale, messages }: Props) {
return <I18nProvider i18n={i18n} locale={locale} messages={messages} routing={routing}>{children}</I18nProvider>;
}
src/app/[locale]/layout.tsx
import { loadMessages, setRequestLocale } from '@/i18n/server';
import { ComviProvider } from './ComviProvider';
export default async function LocaleLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
const messages = await loadMessages(locale);
return <ComviProvider locale={locale} messages={messages}>{children}</ComviProvider>;
}
src/i18n/routing.ts
import { defineRouting } from '@comvi/next/routing';
export const routing = defineRouting({ locales: ['en', 'uk'], defaultLocale: 'en' });

Two config modules, one per runtime, sharing routing.ts. createNextI18nFromInstance() is the companion to createNextI18n for an instance you compose yourself: the factory runs once, inside the call, the instance it returns is what the server helpers resolve, and it must carry the loader capability, so a bare createI18n(...) is rejected at the type level. Only routing options go in the second argument; locale, fallback, catalogs, tags and the compiler belong to the factory. The result has no .use(), and createNextI18nFromInstance is exported from @comvi/next/server and nowhere else, so the server loader never reaches the client graph. The client module composes its own instance without a loader and starts from the messages the layout passes.

setI18n(i18n) is the other way to register: hand it an instance you already built. Calling it again with the same instance is a no-op. A different instance, or a createNextI18nFromInstance() registration beside it, throws in development and production, naming both sources. A registered instance wins over a withComvi alias when an app has both.

setRequestLocale(locale) stores the locale in React’s per-request cache, which is what the no-argument getI18n() and getLocale() read. In the manual setup the layout calls it, because nothing else does; with the server <ComviProvider> it is needed only for generateStaticParams pages that call the no-argument helpers. The manual layout.tsx skips hasLocale and <html> to keep the manual pieces visible; they stay as on the Next.js page.

vitest.config.ts
import path from 'node:path';
import { defineConfig } from 'vitest/config';
export default defineConfig({
resolve: { alias: { '@comvi/next/config': path.resolve(__dirname, 'src/i18n/config.ts') } },
});

A test runner never reads next.config, so the withComvi alias is absent there and a Server Component test that calls getI18n() finds no instance. Alias @comvi/next/config to the config module yourself (resolve.alias in Vitest, moduleNameMapper in Jest), or call setI18n(i18n) in the test setup file. For the same reason @comvi/next cannot be listed in serverExternalPackages: Node would resolve the specifier to the package’s empty stub instead of your module.

prop required what it does
i18n yes the i18n instance; a class instance, so the file must be "use client"
locale yes the locale the server rendered. The provider takes no ssrLocale
messages no the MessagesMap from loadMessages(), added to the cache before children render
routing no enables the locale-aware <Link>, usePathname() and useLocalizedRouter()
autoInit no default true
onError no init-failure handler; defaults to console.error

The server <ComviProvider> renders this component for you, with i18n and routing from your config module and messages from what it loaded. The entry re-exports React’s useI18n, useI18nInstance, useI18nLoader, useI18nPlugins, useLocale, useIsLoading, useSetLocaleTransition, useFormatters, T and the ComponentMap type, plus core’s createI18n, the installers and nextPlugin / nextPluginLazy, so a client component reads the same surface as in a React app. A [locale] segment outside routing.locales does not throw: the provider reports it through i18n.reportError and skips the locale sync.

src/i18n/config.ts
import { createNextI18n, definePlugin, nextPlugin, nextPluginLazy } from '@comvi/next';
import { localeDetectorPlugin } from '@comvi/plugin-locale-detector';
import { routing } from './routing';
export { routing };
export const { i18n } = createNextI18n({ ...routing })
// Client only: on the server the locale is the request's.
.use(nextPlugin(localeDetectorPlugin({ supportedLocales: ['en', 'uk'] }), { runtime: 'client' }))
// Client only, code-split, development only, optional.
.use(
nextPluginLazy(
() => import('@comvi/plugin-in-context-editor').then((m) => m.inContextEditorPlugin()),
{ runtime: 'client', environment: 'development', required: false },
),
)
// Server only, code-split: the CDN fetch happens where the request is.
.use(
nextPluginLazy(
() =>
import('@comvi/plugin-fetch-loader').then((m) =>
m.cdnLoaderPlugin({ cdnUrl: 'https://cdn.comvi.io/<project-token>' }),
),
{ runtime: 'server' },
),
)
// An inline plugin of your own needs no scope.
.use(definePlugin((i18n) => i18n.on('localeChanged', () => {})));
installer what it registers
nextPlugin(plugin, options?) plugin, run at init() only where runtime ("client" / "server", omit for both) and environment ("all" / "development" / "production") say
nextPluginLazy(load, options?) the plugin load() resolves to, imported at most once and only where the scope says

Both take a raw plugin, so cdnLoaderPlugin is the right name there. The cdnLoader installer in that slot fails at init() with E_INSTALLER_AS_PLUGIN, and a raw plugin in a plain .use() throws E_USE_NOT_INSTALLER. options also carries required (default true; an optional plugin’s failure is reported and init continues) and timeout in milliseconds (default 10000). A plugin scoped out of the current runtime is still imported by nextPlugin; nextPluginLazy defers the import too. An installer that returns a different object is refused with E_USE_INSTANCE_IDENTITY. A scoped plugin keeps config.ts client-safe only if its import is: nextPluginLazy with runtime: 'server' is how a server-side fetch stays out of the browser bundle while the module is shared.

withComvi aliases @comvi/next/config to your config module, so the server helpers read its i18n export at call time; in the manual setup setI18n() or createNextI18nFromInstance() fill a module-level cell instead, and the cell wins when both exist. getI18n({ locale }) takes the instance from there, runs init() once per instance if it has not run yet, loads the default namespace for that locale and its fallback chain if the cache lacks it, and hands back a t that passes locale into every lookup explicitly. The instance’s own locale property is never written on the server. Two requests in different locales share the catalog cache and nothing else, so they cannot race. <ComviProvider> and setRequestLocale() write the locale into a store created with React’s cache(), scoped to the current request, and only the no-argument helpers read it.

Only data crosses to the client. The provider walks the requested locale and its fallback chain, loads each missing locale:namespace through the composed loader, and passes plain objects keyed "locale:namespace" as props to a "use client" component, so React serializes them into the RSC payload. The i18n instance is not serialized at all: the client provider imports the same @comvi/next/config alias, so the client has its own copy of your module, built from the same expression.

On the client, the hydrating render reads messages through a read view of the instance and renders in the locale prop, so it reads the same text the server rendered without writing anything. At the first commit, the provider adds messages to the cache and then sets i18n.locale from the locale prop, in the browser only. During the server render it validates the locale but never writes the instance, because writing a module-scope instance there would leak one request’s locale into another’s tree. A plugin with runtime: 'server' never runs in the client graph and vice versa; the loader fetching from the CDN at the server is how the provider has anything to ship.