Next.js: locale routing
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.
Define the routes
Section titled “Define the routes”import { defineRouting } from '@comvi/next/routing';
export const routing = defineRouting({ locales: ['en', 'uk'], defaultLocale: 'en', localePrefix: 'as-needed',});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.
Middleware
Section titled “Middleware”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:
- a custom
detectLocale(request)callback, when configured; - the URL pathname (
/uk/…isuk); localeDetection.order, by default['query', 'cookie', 'accept-language']; thequerysource reads nothing untilqueryParamnames a parameter, so by default this is theNEXT_LOCALEcookie, then theAccept-Languageheader;- the default locale.
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' },});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.
Static params and the provider
Section titled “Static params and the provider”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.
Switch the locale
Section titled “Switch the locale”'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.
Locale routing modes
Section titled “Locale routing modes”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.
How it works
Section titled “How it works”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.
Read next
Section titled “Read next”- Server and client APIs:
loadMessages(), the provider props and plugin scoping. - Switch the locale: detection, persistence and the fallback chain.
- Next.js API reference: every routing, navigation and middleware option.