Skip to content

Next.js

Comvi i18n integrates with Next.js 14+ via the @comvi/next package, supporting the App Router, Server Components, middleware-based locale routing, and streaming SSR.

Terminal window
npm install @comvi/next @comvi/core

Peer dependencies:

  • next: ^14.0.0 || ^15.0.0
  • react: ^18.0.0 || ^19.0.0
  1. Create the i18n configuration:

    i18n/config.ts
    import { createNextI18n } from "@comvi/next";
    export const nextI18n = createNextI18n({
    locales: ["en", "de", "fr"],
    defaultLocale: "en",
    localePrefix: "as-needed", // or "always" / "never"
    fallbackLocale: "en",
    apiKey: process.env.COMVI_API_KEY,
    });
    // Export for middleware and server
    export const { i18n, routing } = nextI18n;
    // Register translations.
    // Keys are "locale" (default namespace) or "locale:namespace".
    i18n.registerLoader({
    en: () => import("../locales/en.json"),
    de: () => import("../locales/de.json"),
    "en:admin": () => import("../locales/admin/en.json"),
    });
    // Plugin registration (optional).
    // useServerLazy takes a loader that resolves to a plugin — use it for plugins
    // you only want on the server, dynamically imported. (useServer takes an
    // already-resolved plugin; useClient / useClientLazy do the same on the client.)
    nextI18n.useServerLazy(() =>
    import("@comvi/plugin-fetch-loader").then((m) =>
    m.FetchLoader({ cdnUrl: "https://cdn.comvi.io/your-distribution-id" }),
    ),
    );
  2. Register the i18n instance for server use:

    getI18n(), getLocale(), and loadTranslations() read from a module-level i18n reference that you must set once with setI18n(). Do this in a server-only side-effect file:

    i18n/server.ts
    import "server-only";
    import { setI18n } from "@comvi/next/server";
    import { i18n } from "./config";
    setI18n(i18n);
    // Re-export the server helpers for convenience
    export { i18n, routing } from "./config";
    export { getI18n, getLocale, loadTranslations } from "@comvi/next/server";

    Then import this file once at the top of your root layout (app/[locale]/layout.tsx) so the side effect runs:

    import "@/i18n/server";

    If you skip this, getI18n() throws "[comvi/next] i18n not configured. Call setI18n(i18n) in your i18n configuration file."

  3. Add middleware:

    middleware.ts
    import { createMiddleware } from "@comvi/next/middleware";
    import { routing } from "./i18n/config";
    export default createMiddleware(routing);
    export const config = {
    matcher: ["/((?!api|_next/static|_next/image|favicon.ico|.*\\.png).*)"],
    };

    The middleware detects locale from the URL, cookies, or Accept-Language header, and handles redirects according to localePrefix mode.

  4. Create the locale layout:

    Import @/i18n/server once here so setI18n() runs before any Server Component calls getI18n().

    app/[locale]/layout.tsx
    import "@/i18n/server";
    import { setRequestLocale, loadTranslations } from "@comvi/next/server";
    import { ComviProvider } from "./ComviProvider";
    export async function generateStaticParams() {
    return [{ locale: "en" }, { locale: "de" }, { locale: "fr" }];
    }
    export default async function LocaleLayout({
    children,
    params,
    }: {
    children: React.ReactNode;
    params: Promise<{ locale: string }>;
    }) {
    const { locale } = await params;
    // Set locale in async context for server functions
    setRequestLocale(locale);
    // Pre-load translations on server and pass them to the client provider
    const messages = await loadTranslations(locale);
    return (
    <html lang={locale}>
    <head>
    <meta charSet="utf-8" />
    </head>
    <body>
    <ComviProvider locale={locale} messages={messages}>
    {children}
    </ComviProvider>
    </body>
    </html>
    );
    }
  5. Wrap client-side in I18nProvider:

    The i18n class instance can’t be serialized from server to client, so the client provider lives in its own "use client" file that imports i18n directly.

    app/[locale]/ComviProvider.tsx
    "use client";
    import { I18nProvider } from "@comvi/next/client";
    import type { MessagesMap } from "@comvi/next/client";
    import { i18n, routing } from "@/i18n/config";
    export function ComviProvider({
    children,
    locale,
    messages,
    }: {
    children: React.ReactNode;
    locale: string;
    messages: MessagesMap;
    }) {
    return (
    <I18nProvider
    i18n={i18n}
    locale={locale}
    messages={messages}
    routing={routing}
    >
    {children}
    </I18nProvider>
    );
    }
  6. Use in Server Components:

    app/[locale]/page.tsx
    import { getI18n, setRequestLocale } from "@comvi/next/server";
    export default async function HomePage({
    params,
    }: {
    params: Promise<{ locale: string }>;
    }) {
    const { locale } = await params;
    setRequestLocale(locale);
    const { t } = await getI18n();
    return (
    <main>
    <h1>{t("home.title")}</h1>
    <p>{t("home.description", { count: 5 })}</p>
    </main>
    );
    }
  7. Use in Client Components:

    app/[locale]/components/Switcher.tsx
    "use client";
    import { useI18n } from "@comvi/next/client";
    export function LocaleSwitcher() {
    const { locale, setLocale } = useI18n();
    const handleChange = async (newLocale: string) => {
    await setLocale(newLocale);
    // Note: Next.js client-side routing may be needed for full page switch
    };
    return (
    <select value={locale} onChange={(e) => handleChange(e.target.value)}>
    <option value="en">English</option>
    <option value="de">Deutsch</option>
    <option value="fr">Français</option>
    </select>
    );
    }
interface CreateNextI18nOptions {
// Routing (required)
locales: string[];
defaultLocale: string;
localePrefix?: "always" | "as-needed" | "never"; // default "as-needed"
pathnames?: Record<string, Partial<Record<string, string>>>; // locale-specific public slugs
// i18n (optional)
apiKey?: string;
ns?: string[]; // namespaces to load on init
translation?: Record<string, Record<string, TranslationValue>>; // seed translations (no loader)
fallbackLocale?: string | string[]; // default: same as defaultLocale
defaultNs?: string; // default "default"
devMode?: boolean; // default: NODE_ENV === "development"
basicHtmlTags?: string[]; // tags allowed in tag interpolation
// The core callback receives a MissingKeyInfo ({ key, locale, namespace }) and may return
// a replacement TranslationResult, or void to use the default fallback chain.
onMissingKey?: (info: MissingKeyInfo) => TranslationResult | void;
}

Returns an object with:

  • i18n: I18n — core instance (use with <I18nProvider> and setI18n)
  • routing: Required<RoutingConfig> — passed to middleware and navigation
  • use(plugin, options?): this — register a resolved plugin (chainable)
  • useClient(plugin, options?): this — client-only plugin (resolved)
  • useServer(plugin, options?): this — server-only plugin (resolved)
  • useClientLazy(loadPlugin, options?): this — client-only plugin, dynamically imported
  • useServerLazy(loadPlugin, options?): this — server-only plugin, dynamically imported

The options for useClient/useServer/useClientLazy/useServerLazy is ScopedPluginOptions — the standard plugin options plus environment?: "all" | "development" | "production" (default "all"), which lets you, for example, register the in-context editor plugin only in dev builds.

Store locale in async context. Call at the start of each layout/page Server Component that uses getI18n() (and required for static rendering with generateStaticParams()):

import { setRequestLocale, getI18n } from "@comvi/next/server";
export default async function Page({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
setRequestLocale(locale); // Must be called before getI18n
const { t } = await getI18n();
return <h1>{t("home.title")}</h1>;
}

Get the i18n instance with the locale from the async context:

import { getI18n, setRequestLocale } from "@comvi/next/server";
export default async function Page({ params }) {
const { locale } = await params;
setRequestLocale(locale);
const { t, hasTranslation } = await getI18n();
return <h1>{t("key")}</h1>;
}

Get the current request’s locale. Async because it may read from next/headers:

import { getLocale, setRequestLocale } from "@comvi/next/server";
export default async function Page({ params }) {
const { locale } = await params;
setRequestLocale(locale);
const currentLocale = await getLocale();
// currentLocale === locale
}

Pre-load translations on the server. Returns a serializable map keyed by "locale:namespace" that you can pass to the client provider for hydration. Defaults to the default namespace; pass { namespaces: [...] } to load more:

import { loadTranslations } from "@comvi/next/server";
export default async function Layout({ params }) {
const { locale } = await params;
// Default namespace only
const messages = await loadTranslations(locale);
// Or specific namespaces
const adminMessages = await loadTranslations(locale, {
namespaces: ["common", "admin"],
});
return <>{/* pass messages to <I18nProvider> */}</>;
}

Same as React useI18n but works in Next.js client components:

"use client";
import { useI18n } from "@comvi/next/client";
export function MyComponent() {
const { t, locale, setLocale } = useI18n("admin");
return (
<div>
<h1>{t("dashboard.title")}</h1>
<button onClick={() => setLocale("de")}>Deutsch</button>
</div>
);
}
<I18nProvider
i18n={i18n}
locale="en" // hydration locale (required) — server locale
messages={messages} // optional pre-loaded translations from loadTranslations()
routing={routing} // optional — enables locale-aware <Link>/usePathname/router
autoInit // default true — auto-init i18n on mount
onError={(error) => {}} // optional — init error handler (defaults to console)
>
{children}
</I18nProvider>

This is the Next-aware provider from @comvi/next/client — it accepts a plain locale prop (rather than @comvi/react’s ssrInitialLocale), keeps i18n.locale in sync across client-side navigation, and adds the pre-loaded messages to the cache before children render.

Creates a middleware function that handles locale detection and routing:

import { createMiddleware } from "@comvi/next/middleware";
import { routing } from "./i18n/config";
export default createMiddleware(routing);
export const config = {
matcher: ["/((?!api|_next/static|.*\\..*).*)"],
};

Middleware detects locale in this order:

  1. Custom detectLocale(request) callback, if you configured one (highest priority)
  2. URL pathname (/de/... → locale de)
  3. The configured localeDetection.order — by default ["cookie", "accept-language"] (cookie name NEXT_LOCALE)
  4. Default locale

See MiddlewareConfig in the API reference for the full detection-config shape (order, cookieName, cookieSecure, headerName, resolveAcceptLanguage, detectLocale).

localePrefix: "always" → All routes prefixed: /en/about, /de/about, /about (redirects)
localePrefix: "as-needed" → Only non-default: /en/about, /about (English)
localePrefix: "never" → No prefix: /about (routing via cookie/header)

use(), useClient(), and useServer() register an already-resolved plugin; the client/server variants only run the plugin on the matching runtime (detected from NEXT_RUNTIME / typeof window). The *Lazy variants take a loader function that resolves to the plugin — handy when the plugin should be code-split:

import { LocaleDetector } from "@comvi/plugin-locale-detector";
// Client only (typeof window !== 'undefined')
nextI18n.useClient(LocaleDetector({ /* ... */ }));
// Server only (NEXT_RUNTIME present)
nextI18n.useServerLazy(() =>
import("@comvi/plugin-fetch-loader").then((m) => m.FetchLoader({ /* ... */ })),
);
// Dev-only client plugin (e.g. the in-context editor)
nextI18n.useClientLazy(
() => import("@comvi/plugin-in-context-editor").then((m) => m.InContextEditorPlugin()),
{ environment: "development", required: false },
);
// Both (registered everywhere)
nextI18n.use(MyCustomPlugin());

For localePrefix: "always" / "as-needed", switching locale also means navigating to the localized path. Use the locale-aware router from @comvi/next/navigation:

"use client";
import { useI18n } from "@comvi/next/client";
import { usePathname, useLocalizedRouter } from "@comvi/next/navigation";
export function LanguageSwitcher() {
const { locale } = useI18n();
const pathname = usePathname(); // current path without locale prefix
const router = useLocalizedRouter();
return (
<select
value={locale}
onChange={(e) => router.push(pathname, e.target.value)}
>
<option value="en">English</option>
<option value="de">Deutsch</option>
</select>
);
}

(setLocale() from useI18n() updates the active locale in the i18n instance; the navigation above keeps the URL in sync. With localePrefix: "never" you can call setLocale(newLocale) alone since the middleware reads the locale cookie.)

import { Link } from "@comvi/next/navigation";
export function LocaleLink({
href,
children,
}: {
href: string;
children: React.ReactNode;
}) {
return <Link href={href}>{children}</Link>;
}

In layouts/pages under app/[locale]/, validate the locale segment with hasLocale from @comvi/next/routing and notFound() for unknown values — it also narrows the type:

import { hasLocale } from "@comvi/next/routing";
import { notFound } from "next/navigation";
import { routing } from "@/i18n/config";
export default async function Page({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
if (!hasLocale(routing.locales, locale)) {
notFound();
}
// locale is now typed as a member of routing.locales
return <div>{locale}</div>;
}
"use client";
import { I18nProvider } from "@comvi/next/client";
import { i18n, routing } from "@/i18n/config";
export function ErrorBoundary({
children,
locale,
}: {
children: React.ReactNode;
locale: string;
}) {
return (
<I18nProvider
i18n={i18n}
locale={locale}
routing={routing}
onError={(error) => {
console.error("i18n error:", error);
}}
>
{children}
</I18nProvider>
);
}