# Next.js

Add Comvi i18n to a Next.js App Router app: install @comvi/next, point next.config at one config module, wrap the locale layout, translate in both runtimes and load from the CDN.

Canonical: https://comvi.io/docs/i18n/next

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](/docs/i18n/quick-start) creates.

Open the [Next.js example in StackBlitz](https://stackblitz.com/github/comvi-io/comvi-js/tree/main/examples/next) to see server rendering, the locale in the URL (`/de`), plurals and `<T>` with a link inside the text running.

## Install
**npm**

```bash
npm install @comvi/next
```

**pnpm**

```bash
pnpm add @comvi/next
```

**yarn**

```bash
yarn add @comvi/next
```

**bun**

```bash
bun 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`.

## Setup

`next.config.ts`

```ts

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`

```ts

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

`src/i18n/config.ts`

```ts

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`

```tsx

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

## Use in components

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

```tsx

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`

```tsx
'use 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](/docs/i18n/react#use-in-components) come from
`@comvi/next/client`.

### 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](/docs/i18n/tag-interpolation).

## Load from Comvi

`src/i18n/config.ts`

```ts

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](/docs/i18n/connect-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](/docs/platform/cdn#url-format).
If your keys use ICU (`{count, plural, …}`), add `compiler: icuCompiler` as on
[Pluralize with ICU](/docs/i18n/pluralization). The platform and the compiler are independent:
either works without the other.
## 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

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

## 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](/docs/i18n/guides/next-server-client#manual-setup).

> **Never compose a locale detector in config.ts**
>
> The server instance is process-wide, so a detector composed there runs once, inside the first
> `init()`, and never again per request. Every source it reads (`querystring`, `localStorage`,
> `sessionStorage`, `cookie`, `navigator`) needs a browser, so on the server it falls through to the
> `locale` option and detects nothing. Scope it to the client with
> `nextPlugin(localeDetectorPlugin(…), { runtime: 'client' })`, where there is exactly one visitor;
> see [Plugin scoping](/docs/i18n/guides/next-server-client#plugin-scoping).

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

| 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](/docs/i18n/errors#comvinext-server-errors) |
| `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](/docs/i18n/troubleshooting).

## Read next

- [Next.js: locale routing](/docs/i18n/guides/next-routing): the middleware, `<Link>` and prefixed paths.
- [Connect to Comvi](/docs/i18n/connect-comvi): the CDN URL, and a wording change that ships without a redeploy.
- [Next.js API reference](/docs/i18n/api/next): every export of the seven entries.
