Next.js: server and client
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.
Load translations on the server
Section titled “Load translations on the server”Namespaces on <ComviProvider>
Section titled “Namespaces on <ComviProvider>”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>;}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>;}'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.
Catalogs from an import map
Section titled “Catalogs from an import map”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.
Server APIs (@comvi/next/server)
Section titled “Server APIs (@comvi/next/server)”getI18n({ locale, ns? })returnst,hasTranslationandlocale, bound to the locale you pass. It reads and writes nothing in the request store, sogenerateMetadataand a page can name different locales in one request. There is notRaw; rich text is a client concern. A localerouting.localesdoes not list renders from the fallback chain and warns once in development.getI18n()without a locale reads the request store<ComviProvider>filled, or thex-comvi-localeheader 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 readnext/headers. A header value outsiderouting.localesis ignored androuting.defaultLocaleis returned.loadMessages(locale, options?)returns the"locale:namespace"map, for a manual<I18nProvider messages>or to preload a namespacegetI18n()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 withoutwithComviinnext.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.
Manual setup
Section titled “Manual setup”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.
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';import { createNextI18n } from '@comvi/next';import { routing } from './routing';
export const { i18n } = createNextI18n({ ...routing });
export { routing };'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>;}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>;}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.
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.
Client provider (@comvi/next/client)
Section titled “Client provider (@comvi/next/client)”| 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.
Plugin scoping
Section titled “Plugin scoping”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.
How it works
Section titled “How it works”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.
Read next
Section titled “Read next”- Locale routing:
defineRouting, the middleware and localized links. - Render on the server: per-request instances and hydration rules across frameworks.
- Write a plugin: writing an installer, and what a plugin may do at
init().