# Next.js: server and client

What crosses the @comvi/next server/client boundary: ComviProvider and getI18n({ locale }), namespaces, the manual setup for server-only loaders, the client provider props and plugin scoping with nextPlugin.

Canonical: https://comvi.io/docs/i18n/guides/next-server-client

With `@comvi/next` set up as on the [Next.js](/docs/i18n/next) 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

### Namespaces on `<ComviProvider>`

`src/app/[locale]/admin/layout.tsx`

```tsx

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`

```tsx

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`

```tsx
'use 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

`src/i18n/config.ts`

```ts

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](/docs/i18n/loading-translations).

## Server APIs (`@comvi/next/server`)

- **`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.

## 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.

`src/i18n/server.ts`

```ts

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`

```ts

export const { i18n } = createNextI18n({ ...routing });

export { routing };
```

`src/app/[locale]/ComviProvider.tsx`

```tsx
'use 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`

```tsx

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`

```ts

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](/docs/i18n/next) page.

### Tests

`vitest.config.ts`

```ts

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`)

| 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](/docs/i18n/react#use-in-components). A `[locale]` segment outside
`routing.locales` does not throw: the provider reports it through `i18n.reportError` and skips the
locale sync.

## Plugin scoping

`src/i18n/config.ts`

```ts

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

`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

- [Locale routing](/docs/i18n/guides/next-routing): `defineRouting`, the middleware and localized links.
- [Render on the server](/docs/i18n/ssr): per-request instances and hydration rules across frameworks.
- [Write a plugin](/docs/i18n/plugins/custom-plugins): writing an installer, and what a plugin may do at `init()`.
