# Next.js: locale routing

Locale-prefixed URLs on the Next.js App Router: defineRouting, createMiddleware, generateStaticParams, <Link>, usePathname, useLocalizedRouter and the three localePrefix modes.

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

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

`src/i18n/routing.ts`

```ts

export const routing = defineRouting({
locales: ['en', 'uk'],
defaultLocale: 'en',
localePrefix: 'as-needed',
});
```

`src/i18n/config.ts`

```ts

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

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

`src/middleware.ts`

```ts

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`

```ts

export const routing = defineRouting({
locales: ['en', 'uk'],
defaultLocale: 'en',
localeCookie: { name: 'locale', maxAge: 60 * 60 * 24 * 30, sameSite: 'strict' },
});
```

`src/middleware.ts`

```ts

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](/docs/i18n/api/next#localecookieoptions). The `Accept-Language` matching
rules are on [Language detection on the server](/docs/i18n/ssr#how-the-accept-language-header-is-matched).

## Static params and the provider

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

```tsx

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

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

```tsx
'use client';

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

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

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

- [Server and client APIs](/docs/i18n/guides/next-server-client): `loadMessages()`, the provider props and plugin scoping.
- [Switch the locale](/docs/i18n/language-switching): detection, persistence and the fallback chain.
- [Next.js API reference](/docs/i18n/api/next): every routing, navigation and middleware option.
