Skip to content
Comvi

Next.js: locale routing

View .md

With @comvi/next set up as on the Next.js page, put the locale in the URL: defineRouting holds the rules, the middleware redirects and rewrites each request, and the navigation primitives build localized hrefs from the same rules.

src/i18n/routing.ts
import { defineRouting } from '@comvi/next/routing';
export const routing = defineRouting({
locales: ['en', 'uk'],
defaultLocale: 'en',
localePrefix: 'as-needed',
});
src/i18n/config.ts
import { createNextI18n } from '@comvi/next';
import { cdnLoader } from '@comvi/plugin-fetch-loader';
import { routing } from './routing';
export const { i18n } = createNextI18n({ ...routing }).use(
cdnLoader({ cdnUrl: 'https://cdn.comvi.io/<project-token>' }),
);
export { routing };

The routing module holds the URL rules and nothing else: no i18n instance, no loader, no catalogs. localePrefix is the one option the Next.js page leaves at its default. config.ts spreads the rules into createNextI18n so the i18n instance and the middleware share one locale list; createNextI18n returns { i18n, routing, use }, and .use(installer) composes a loader or a plugin onto the instance. The CDN URL is on Connect to Comvi.

If your keys use ICU ({count, plural, …}), add compiler: icuCompiler as on Pluralize with ICU. The platform and the compiler are independent: either works without the other.

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

Import routing from the core-free module, not from config.ts. The Edge runtime executes every module the entry reaches, and config.ts builds the i18n instance and its loader, which has no business running once per request at the edge. The middleware resolves the locale in this order:

  1. a custom detectLocale(request) callback, when configured;
  2. the URL pathname (/uk/… is uk);
  3. localeDetection.order, by default ['query', 'cookie', 'accept-language']; the query source reads nothing until queryParam names a parameter, so by default this is the NEXT_LOCALE cookie, then the Accept-Language header;
  4. the default locale.
src/i18n/routing.ts
import { defineRouting } from '@comvi/next/routing';
export const routing = defineRouting({
locales: ['en', 'uk'],
defaultLocale: 'en',
localeCookie: { name: 'locale', maxAge: 60 * 60 * 24 * 30, sameSite: 'strict' },
});
src/middleware.ts
import { createMiddleware } from '@comvi/next/middleware';
import { routing } from './i18n/routing';
export default createMiddleware({
...routing,
localeDetection: { order: ['query', 'cookie', 'accept-language'], queryParam: 'lang' },
});

localeCookie is false, a cookie name, or { name, maxAge, secure, sameSite, domain }, and it belongs to the routing config next to localePrefix, so the navigation primitives see it too. localeDetection is false or { order, queryParam, headerName, resolveAcceptLanguage }, and it belongs to createMiddleware, like detectLocale. localeCookie: false persists nothing and localeDetection: false leaves the URL path alone in charge; each switch leaves the other untouched. The Nuxt module takes the same two options, with the same values and defaults except the cookie name, so one setting means one behaviour in both. Every attribute and default is on the API reference. The Accept-Language matching rules are on Language detection on the server.

src/app/[locale]/layout.tsx
import { notFound } from 'next/navigation';
import { hasLocale } from '@comvi/next/routing';
import { ComviProvider } from '@comvi/next/server';
import { routing } from '@/i18n/routing';
type Props = { children: React.ReactNode; params: Promise<{ locale: string }> };
export function generateStaticParams() {
return routing.locales.map((locale) => ({ locale }));
}
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> passes routing from your config module to the client provider, which is what makes <Link>, usePathname() and useLocalizedRouter() honor localePrefix. Without a routing export they prefix every href as /{locale}{path}, so <Link href="/about"> on the default locale emits /en/about under as-needed. generateStaticParams() is the one addition to the layout from the Next.js page: it derives from the same locale list, and a statically rendered page has no request, so its Server Components pass the locale to getI18n({ locale }) or call setRequestLocale(locale) before a no-argument helper. The same goes for generateMetadata, not-found.tsx and anything else rendered outside the <ComviProvider> tree: there the locale is whatever you pass. The middleware only rewrites paths it recognizes, so the layout still validates the segment with hasLocale.

src/app/[locale]/LocaleSwitcher.tsx
'use client';
import { useI18n } from '@comvi/next/client';
import { usePathname, useLocalizedRouter } from '@comvi/next/navigation';
export function LocaleSwitcher() {
const { locale } = useI18n();
const pathname = usePathname();
const router = useLocalizedRouter();
return (
<select value={locale} onChange={(e) => router.push(pathname, e.target.value)}>
<option value="en">English</option>
<option value="uk">Українська</option>
</select>
);
}

usePathname() returns the path without the locale prefix, and router.push(path, locale) navigates to the same page in the new locale. The URL is the source of truth, so the provider syncs i18n.locale on navigation. <Link>, usePathname() and useLocalizedRouter() live on @comvi/next/navigation, which is "use client"; hasLocale, defineRouting and createGetPathname live on the server-safe @comvi/next/routing.

localePrefix Default locale Other locale
as-needed (default) /about /uk/about
always /en/about /uk/about
never /about /about (locale from cookie or header)

Matching is segment-based, so /ensemble is never locale en. Trailing slashes survive (/uk/about/ maps to /about/), except the prefixed root, which is always /{locale}. A pathnames key written with a trailing slash matches the same paths as one written without it.

Under as-needed with a uk cookie, a request for /about redirects to /uk/about: the stored preference wins. Nuxt serves the default locale at /about instead.

On every request the matcher lets through, the middleware runs the same steps. It collapses doubled slashes in the path and skips /_next and /api outright. It extracts the locale segment, if any, and resolves the locale in the order listed under Middleware, ending at defaultLocale when nothing matched or the match is not in locales. It strips the prefix, applies pathnames to get the canonical internal path, and asks the routing rules what the public URL for that path in that locale should be. If the public URL differs from what was requested, the response is a redirect; otherwise it is a rewrite to /{locale}{path}, the directory your [locale] segment matches. Either way the response carries the x-comvi-locale header, and the NEXT_LOCALE cookie is set for a year, Secure outside development, whenever its value differs from the resolved locale and localeCookie is not false. The cookie is written on document requests and on a redirect away from a locale-prefixed URL, not on a client-router prefetch, so a switch to the default locale under as-needed is recorded too.

That header is the bridge to the no-argument server APIs. getLocale() reads the per-request store that <ComviProvider> or setRequestLocale() fills, and when the store is empty it falls back to x-comvi-locale, so a Server Component outside the provider still translates in the right locale as long as the request passed through the middleware. The header is trusted only when its value is in routing.locales; anything else is ignored, routing.defaultLocale applies, and development warns with [@comvi/next] Ignored the x-comvi-locale header "xx": it is not in routing.locales (en, de). getI18n({ locale }) reads neither: the locale comes from the [locale] param, which is why it works for a page rendered at build time from generateStaticParams().

The middleware bundle holds everything its entry imports and runs it at the edge on each request. routing.ts imports defineRouting and nothing from @comvi/core, so that bundle is a few path functions and a locale list. config.ts would bring the whole instance and the loader along for a function whose only job is to read a path and a cookie.