diff --git a/docs/pages/docs/getting-started/app-router/with-i18n-routing.mdx b/docs/pages/docs/getting-started/app-router/with-i18n-routing.mdx
index cfc8ba3cb..633ea2bea 100644
--- a/docs/pages/docs/getting-started/app-router/with-i18n-routing.mdx
+++ b/docs/pages/docs/getting-started/app-router/with-i18n-routing.mdx
@@ -107,7 +107,7 @@ To share the configuration between these two places, we'll set up `routing.ts`:
```ts filename="src/i18n/routing.ts"
import {defineRouting} from 'next-intl/routing';
-import {createSharedPathnamesNavigation} from 'next-intl/navigation';
+import {createNavigation} from 'next-intl/navigation';
export const routing = defineRouting({
// A list of all locales that are supported
@@ -120,7 +120,7 @@ export const routing = defineRouting({
// Lightweight wrappers around Next.js' navigation APIs
// that will consider the routing configuration
export const {Link, redirect, usePathname, useRouter} =
- createSharedPathnamesNavigation(routing);
+ createNavigation(routing);
```
Depending on your requirements, you may wish to customize your routing configuration later—but let's finish with the setup first.
diff --git a/docs/pages/docs/routing.mdx b/docs/pages/docs/routing.mdx
index 4288001dc..5fa0e90d2 100644
--- a/docs/pages/docs/routing.mdx
+++ b/docs/pages/docs/routing.mdx
@@ -41,7 +41,7 @@ Depending on your routing needs, you may wish to consider further settings.
In case you're building an app where locales can be added and removed at runtime, you can provide the routing configuration for the middleware [dynamically per request](/docs/routing/middleware#composing-other-middlewares).
-To create the corresponding navigation APIs, you can [omit the `locales` argument](/docs/routing/navigation#locales-unknown) from `createSharedPathnamesNavigation` in this case.
+To create the corresponding navigation APIs, you can [omit the `locales` argument](/docs/routing/navigation#locales-unknown) from `createNavigation` in this case.
@@ -84,10 +84,7 @@ export const routing = defineRouting({
In this case, requests where the locale prefix matches the default locale will be redirected (e.g. `/en/about` to `/about`). This will affect both prefix-based as well as domain-based routing.
-**Note that:**
-
-1. If you use this strategy, you should make sure that your middleware matcher detects [unprefixed pathnames](/docs/routing/middleware#matcher-no-prefix).
-2. If you use [the `Link` component](/docs/routing/navigation#link), the initial render will point to the prefixed version but will be patched immediately on the client once the component detects that the default locale has rendered. The prefixed version is still valid, but SEO tools might report a hint that the link points to a redirect.
+**Note that:** If you use this strategy, you should make sure that your middleware matcher detects [unprefixed pathnames](/docs/routing/middleware#matcher-no-prefix) for the routing to work as expected.
#### Never use a locale prefix [#locale-prefix-never]
@@ -95,8 +92,8 @@ If you'd like to provide a locale to `next-intl`, e.g. based on user settings, y
However, you can also configure the middleware to never show a locale prefix in the URL, which can be helpful in the following cases:
-1. You're using [domain-based routing](#domains) and you support only a single locale per domain
-2. You're using a cookie to determine the locale but would like to enable static rendering
+1. You want to use [domain-based routing](#domains) and have only one locale per domain
+2. You want to use a cookie to determine the locale while enabling static rendering
```tsx filename="routing.ts" {5}
import {defineRouting} from 'next-intl/routing';
@@ -153,8 +150,8 @@ function Component() {
// Assuming the locale is 'en-US'
const locale = useLocale();
- // Returns 'US'
- new Intl.Locale(locale).region;
+ // Extracts the "US" region
+ const {region} = new Intl.Locale(locale);
}
```
@@ -222,13 +219,6 @@ export const routing = defineRouting({
Localized pathnames map to a single internal pathname that is created via the file-system based routing in Next.js. In the example above, `/de/ueber-uns` will be handled by the page at `/[locale]/about/page.tsx`.
-
- If you're using localized pathnames, you should use
- `createLocalizedPathnamesNavigation` instead of
- `createSharedPathnamesNavigation` for your [navigation
- APIs](/docs/routing/navigation).
-
-
How can I revalidate localized pathnames?
@@ -403,3 +393,72 @@ PORT=3001 npm run dev
```
+
+
+Can I use a different `localePrefix` setting per domain?
+
+Since such a configuration would require reading the domain at runtime, this would prevent the ability to render pages statically. Due to this, `next-intl` doesn't support this configuration out of the box.
+
+However, you can still achieve this by building the app for each domain separately, while injecting diverging routing configuration via an environment variable.
+
+**Example:**
+
+```tsx filename="routing.ts"
+import {defineRouting} from 'next-intl/routing';
+
+export const routing = defineRouting({
+ locales: ['en', 'fr'],
+ defaultLocale: 'en',
+ localePrefix:
+ process.env.VERCEL_PROJECT_PRODUCTION_URL === 'us.example.com'
+ ? 'never'
+ : 'always',
+ domains: [
+ {
+ domain: 'us.example.com',
+ defaultLocale: 'en',
+ locales: ['en']
+ },
+ {
+ domain: 'ca.example.com',
+ defaultLocale: 'en'
+ }
+ ]
+});
+```
+
+
+
+
+Special case: Using `domains` with `localePrefix: 'as-needed'`
+
+Since domains can have different default locales, this combination requires some tradeoffs that apply to the [navigation APIs](/docs/routing/navigation) in order for `next-intl` to avoid reading the current host on the server side (which would prevent the usage of static rendering).
+
+1. [``](/docs/routing/navigation#link): This component will always render a locale prefix on the server side, even for the default locale of a given domain. However, during hydration on the client side, the prefix is potentially removed, if the default locale of the current domain is used. Note that the temporarily prefixed pathname will always be valid, however the middleware will potentially clean up a superfluous prefix via a redirect if the user clicks on a link before hydration.
+2. [`redirect`](/docs/routing/navigation#redirect): When calling this function, a locale prefix is always added, regardless of the provided locale. However, similar to the handling with ``, the middleware will potentially clean up a superfluous prefix.
+3. [`getPathname`](/docs/routing/navigation#getpathname): This function requires that a `domain` is passed as part of the arguments in order to avoid ambiguity. This can either be provided statically (e.g. when used in a sitemap), or read from a header like [`x-forwarded-host`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-Host).
+
+```tsx
+import {getPathname} from '@/i18n/routing';
+import {headers} from 'next/headers';
+
+// Case 1: Statically known domain
+const domain = 'ca.example.com';
+
+// Case 2: Read at runtime (dynamic rendering)
+const domain = headers().get('x-forwarded-host');
+
+// Assuming the current domain is `ca.example.com`,
+// the returned pathname will be `/about`
+const pathname = getPathname({
+ href: '/about',
+ locale: 'en',
+ domain
+});
+```
+
+A `domain` can optionally also be passed to `redirect` in the same manner to ensure that a prefix is only added when necessary. Alternatively, you can also consider redirecting in the middleware or via [`useRouter`](/docs/routing/navigation#usrouter) on the client side.
+
+If you need to avoid these tradeoffs, you can consider building the same app for each domain separately, while injecting diverging routing configuration via an [environment variable](#domains-localeprefix-individual).
+
+
diff --git a/docs/pages/docs/routing/middleware.mdx b/docs/pages/docs/routing/middleware.mdx
index 30c5d27cd..f59072fba 100644
--- a/docs/pages/docs/routing/middleware.mdx
+++ b/docs/pages/docs/routing/middleware.mdx
@@ -14,6 +14,8 @@ The middleware receives a [`routing`](/docs/routing#define-routing) configuratio
2. Applying relevant redirects & rewrites
3. Providing [alternate links](#alternate-links) for search engines
+**Example:**
+
```tsx filename="middleware.ts"
import createMiddleware from 'next-intl/middleware';
import {routing} from './i18n/routing';
diff --git a/docs/pages/docs/routing/navigation.mdx b/docs/pages/docs/routing/navigation.mdx
index 34e7f722a..ca2a84758 100644
--- a/docs/pages/docs/routing/navigation.mdx
+++ b/docs/pages/docs/routing/navigation.mdx
@@ -11,88 +11,54 @@ import Details from 'components/Details';
`next-intl` provides lightweight wrappers around Next.js' navigation APIs like [``](https://nextjs.org/docs/app/api-reference/components/link) and [`useRouter`](https://nextjs.org/docs/app/api-reference/functions/use-router) that automatically handle the user locale and pathnames behind the scenes.
-Depending on if you're using the [`pathnames`](/docs/routing#pathnames) setting, you can pick from one of these functions to create the corresponding navigation APIs:
-
-- `createSharedPathnamesNavigation`: Pathnames are shared across all locales (default)
-- `createLocalizedPathnamesNavigation`: Pathnames are provided per locale (use with `pathnames`)
-
-These functions are typically called in a central module like [`src/i18n/routing.ts`](/docs/getting-started/app-router/with-i18n-routing#i18n-routing) in order to provide easy access to navigation APIs in your components and should receive a [`routing`](/docs/routing) configuration that is shared with the middleware.
-
-
-
+To create these APIs, you can call the `createNavigation` function with your `routing` configuration:
```tsx filename="routing.ts"
-import {createSharedPathnamesNavigation} from 'next-intl/navigation';
+import {createNavigation} from 'next-intl/navigation';
import {defineRouting} from 'next-intl/routing';
-const routing = defineRouting(/* ... */);
+export const routing = defineRouting(/* ... */);
export const {Link, redirect, usePathname, useRouter} =
- createSharedPathnamesNavigation(routing);
+ createNavigation(routing);
```
+This function is typically called in a central module like [`src/i18n/routing.ts`](/docs/getting-started/app-router/with-i18n-routing#i18n-routing) in order to provide easy access to navigation APIs in your components.
+
What if the locales aren't known at build time?
-In case you're building an app where locales can be added and removed at runtime, `createSharedPathnamesNavigation` can be called without the `locales` argument, therefore allowing any string that is encountered at runtime to be a valid locale.
-
-In this case, you'd not use the `defineRouting` function.
+In case you're building an app where locales can be added and removed at runtime, `createNavigation` can be called without the `locales` argument, therefore allowing any string that is encountered at runtime to be a valid locale. In this case, you'd not use the [`defineRouting`](/docs/routing#define-routing) function.
```tsx filename="routing.ts"
-import {createSharedPathnamesNavigation} from 'next-intl/navigation';
+import {createNavigation} from 'next-intl/navigation';
-export const {Link, redirect, usePathname, useRouter} =
- createSharedPathnamesNavigation({
- // ... potentially other routing
- // config, but no `locales` ...
- });
+export const {Link, redirect, usePathname, useRouter} = createNavigation({
+ // ... potentially other routing
+ // config, but no `locales` ...
+});
```
-Note however that the `locales` argument for the middleware is mandatory. However, you can provide the routing configuration for the middleware [dynamically per request](/docs/routing/middleware#composing-other-middlewares).
+Note however that the `locales` argument for the middleware is still mandatory. If you need to fetch the available locales at runtime, you can provide the routing configuration for the middleware [dynamically per request](/docs/routing/middleware#composing-other-middlewares).
-
-
-
-```tsx filename="routing.ts"
-import {createLocalizedPathnamesNavigation} from 'next-intl/navigation';
-import {defineRouting} from 'next-intl/routing';
-
-const routing = defineRouting({
- // ...
- pathnames: {
- // ...
- }
-});
-
-export const {Link, redirect, usePathname, useRouter, getPathname} =
- createLocalizedPathnamesNavigation(routing);
-```
+## APIs
-
- Have a look at the [App Router example](/examples#app-router) to explore a
- working implementation of localized pathnames.
-
+The created navigation APIs are thin wrappers around the equivalents from Next.js and mostly adhere to the same function signatures. Your routing configuration and the user's locale are automatically incorporated.
-
-
+If you're using the [`pathnames`](/docs/routing#pathnames) setting in your routing configuration, the internal pathnames that are accepted for `href` arguments will be strictly typed and localized to the given locale.
How can I ensure consistent usage of navigation APIs?
-To ensure consistent usage in your app, you can consider [linting for usage of these APIs](/docs/workflows/linting#consistent-usage-of-navigation-apis).
+To avoid importing APIs like `` directly from Next.js by accident, you can consider [linting](/docs/workflows/linting#consistent-usage-of-navigation-apis) for the consistent usage of internationalized navigation APIs.
-## APIs
-
### `Link`
-This component wraps [`next/link`](https://nextjs.org/docs/app/api-reference/components/link) and automatically incorporates your routing strategy.
-
-
-
+This component wraps [`next/link`](https://nextjs.org/docs/app/api-reference/components/link) and localizes the pathname as necessary.
```tsx
import {Link} from '@/i18n/routing';
@@ -100,14 +66,28 @@ import {Link} from '@/i18n/routing';
// When the user is on `/en`, the link will point to `/en/about`
About
+// Search params can be added via `query`
+Users
+
// You can override the `locale` to switch to another language
+// (this will set the `hreflang` attribute on the anchor tag)
Switch to German
+```
+
+Depending on if you're using the [`pathnames`](/docs/routing#pathnames) setting, dynamic params can either be passed as:
-// Dynamic params need to be interpolated into the pathname
+```tsx
+// 1. A final string (when not using `pathnames`)
Susan
-```
-If you're providing the `locale` prop, the `hreflang` attribute will be set accordingly on the anchor tag.
+// 2. An object (when using `pathnames`)
+
+ Susan
+
+```
How can I render a navigation link?
@@ -152,130 +132,42 @@ See also the Next.js docs on [creating an active link component](https://nextjs.
-
-
-When using [localized pathnames](/docs/routing#pathnames), the `href` prop corresponds to an internal pathname, but will be mapped to a locale-specific pathname.
-
-```tsx
-import {Link} from '@/i18n/routing';
-
-// When the user is on `/de`, the link will point to `/de/ueber-uns`
-About
-
-// You can override the `locale` to switch to another language
-Switch to English
-
-// Dynamic params can be passed via the object form
-
- Susan
-
-
-// Catch-all params can be passed as arrays
-
- T-Shirts
-
-
-// Search params can be added via `query`
-Users
-```
-
How can I compose the link with its href prop?
-If you need to create a component that receives an `href` prop that is forwarded to `Link` internally, you can retain the type safety of `href` by making your component generic and accepting a `Pathname`. The type argument can then be forwarded to the internal props of `Link`.
+If you need to create a component that receives an `href` prop that is forwarded to `Link` internally, you can compose the props from `Link` with the `ComponentProps` type:
```tsx filename="StyledLink.tsx"
import {ComponentProps} from 'react';
-import {Link, pathnames} from '@/i18n/routing';
-
-export default function StyledLink({
- color,
- href,
- ...rest
-}: {color: 'blue' | 'red'} & ComponentProps>) {
- return ;
-}
-```
-
-
-
-
-How can I render a navigation link?
-
-The [`useSelectedLayoutSegment` hook](https://nextjs.org/docs/app/api-reference/functions/use-selected-layout-segment) from Next.js allows you to detect if a given child segment is active from within the parent layout. Since this returns an internal pathname, it can be matched against an `href` that you can pass to `Link`.
-
-To ensure that only valid pathnames can be passed to the component, we can accept a type argument to be forwarded to the wrapped `Link`.
-
-```tsx filename="NavigationLink.tsx"
-'use client';
-
-import {useSelectedLayoutSegment} from 'next/navigation';
-import {ComponentProps} from 'react';
-import {Link, pathnames} from '@/i18n/routing';
+import {Link} from '@/i18n/routing';
-export default function NavigationLink<
- Pathname extends keyof typeof pathnames
->({href, ...rest}: ComponentProps>) {
- const selectedLayoutSegment = useSelectedLayoutSegment();
- const pathname = selectedLayoutSegment ? `/${selectedLayoutSegment}` : '/';
- const isActive = pathname === href;
+type Props = ComponentProps & {
+ color: 'blue' | 'red';
+};
- return (
-
- );
+export default function StyledLink({color, href, ...rest}: Props) {
+ return ;
}
```
-```tsx
-
-```
-
-See also the Next.js docs on [creating an active link component](https://nextjs.org/docs/app/api-reference/functions/use-selected-layout-segment#creating-an-active-link-component).
+In case you're using the [`pathnames`](/docs/routing#pathnames) setting, the `href` prop of the wrapping component will now be strictly typed based on your routing configuration.
-How can I link to unknown routes?
+How can I link to unknown routes when using the `pathnames` setting?
-The navigation APIs are strictly typed and only allow routes specified in the `pathnames` config. If you need to link to unknown routes in certain places, you can either disable the type checking on a case-by-case basis …
+In this case, the navigation APIs are strictly typed and only allow routes specified in the `pathnames` config. If you need to link to unknown routes in certain places, you can disable the type checking on a case-by-case basis:
```tsx
// @ts-expect-error
...
```
-… or globally configure `createLocalizedPathnamesNavigation` to accept arbitrary strings too:
-
-```tsx filename="routing.ts"
-// ...
-
-export const {Link, redirect, usePathname, useRouter} =
- createLocalizedPathnamesNavigation({
- locales,
- pathnames: pathnames as typeof pathnames & Record
- });
-```
+Unknown routes will be passed through as-is, but will receive relevant locale prefixes in case of absolute pathnames.
-
-
-
How does prefetching of localized links work?
Just like `next/link`, by default all links are prefetched. The one exception to this is that links to other locales aren't prefetched, because this may result in prematurely overwriting the locale cookie.
@@ -284,10 +176,8 @@ Just like `next/link`, by default all links are prefetched. The one exception to
### `useRouter`
-If you need to navigate programmatically, e.g. in an event handler, `next-intl` provides a convience API that wraps [`useRouter` from Next.js](https://nextjs.org/docs/app/api-reference/functions/use-router) and automatically applies the locale of the user.
+If you need to navigate programmatically, e.g. in an event handler, `next-intl` provides a convience API that wraps [`useRouter` from Next.js](https://nextjs.org/docs/app/api-reference/functions/use-router) and localizes the pathname accordingly.
-
-
```tsx
'use client';
@@ -298,68 +188,35 @@ const router = useRouter();
// When the user is on `/en`, the router will navigate to `/en/about`
router.push('/about');
+// Search params can be added via `query`
+router.push({
+ pathname: '/users',
+ query: {sortBy: 'name'}
+});
+
// You can override the `locale` to switch to another language
router.replace('/about', {locale: 'de'});
-
-// Dynamic params need to be interpolated into the pathname
-router.push('/users/12', {locale: 'de'});
-
-````
-
-
-How can I change the locale for the current page?
-
-By combining [`usePathname`](#usepathname) with [`useRouter`](#userouter), you can change the locale for the current page programmatically.
-
-```tsx
-'use client';
-
-import {usePathname, useRouter} from '@/i18n/routing';
-
-const pathname = usePathname();
-const router = useRouter();
-
-router.replace(pathname, {locale: 'de'});
```
-
-
-
-
-When using [localized pathnames](/docs/routing#pathnames), the provided `href` corresponds to an internal pathname, but will be mapped to a locale-specific pathname.
+Depending on if you're using the [`pathnames`](/docs/routing#pathnames) setting, dynamic params can either be passed as:
```tsx
-'use client';
-
-import {useRouter} from '@/i18n/routing';
-
-const router = useRouter();
-
-// When the user is on `/de`, the router will navigate to `/de/ueber-uns`
-router.push('/about');
-
-// You can override the `locale` to switch to another language
-router.replace('/about', {locale: 'en'});
+// 1. A final string (when not using `pathnames`)
+router.push('/users/12');
-// Dynamic params need to be provided as objects
+// 2. An object (when using `pathnames`)
router.push({
pathname: '/users/[userId]',
- params: {userId: '12'}
-});
-
-// Search params can be added via `query`
-router.push({
- pathname: '/users',
- query: {sortBy: 'name'}
+ params: {userId: '5'}
});
-````
+```
-
+
How can I change the locale for the current page?
-By combining [`usePathname`](#usepathname) with [`useRouter`](#userouter), you can change the locale for the current page programmatically.
+By combining [`usePathname`](#usepathname) with [`useRouter`](#userouter), you can change the locale for the current page programmatically by navigating to the same pathname, while overriding the `locale`.
-Note that if you have dynamic params on some routes, you should pass those as well to potentially resolve an internal pathname.
+Depending on if you're using the [`pathnames`](/docs/routing#pathnames) setting, you optionally have to forward `params` to potentially resolve an internal pathname.
```tsx
'use client';
@@ -369,8 +226,12 @@ import {useParams} from 'next/navigation';
const pathname = usePathname();
const router = useRouter();
-const params = useParams();
+// Without `pathnames`: Pass the current `pathname`
+router.replace(pathname, {locale: 'de'});
+
+// With `pathnames`: Pass `params` as well
+const params = useParams();
router.replace(
// @ts-expect-error -- TypeScript will validate that only known `params`
// are used in combination with a given `pathname`. Since the two will
@@ -381,15 +242,10 @@ router.replace(
```
-
-
### `usePathname`
-To retrieve the pathname without a potential locale prefix, you can call `usePathname`.
-
-
-
+To retrieve the current pathname without a potential locale prefix, you can call `usePathname`.
```tsx
'use client';
@@ -400,69 +256,49 @@ import {usePathname} from '@/i18n/routing';
const pathname = usePathname();
```
-
-
-
-When using [localized pathnames](/docs/routing#pathnames), the returned pathname will correspond to an internal pathname.
+Note that if you're using the [`pathnames`](/docs/routing#pathnames) setting, the returned pathname will correspond to an internal pathname template (dynamic params will not be replaced by their values).
```tsx
-'use client';
-
-import {usePathname} from '@/i18n/routing';
-
// When the user is on `/de/ueber-uns`, this will be `/about`
const pathname = usePathname();
-```
-Note that internal pathnames are returned without params being resolved (e.g. `/users/[userId]`).
-
-
-
+// When the user is on `/de/neuigkeiten/produktneuheit-94812`,
+// this will be `/news/[articleSlug]-[articleId]`
+const pathname = usePathname();
+```
### `redirect`
-If you want to interrupt the render and redirect to another page, you can invoke the `redirect` function. This wraps [the `redirect` function from Next.js](https://nextjs.org/docs/app/api-reference/functions/redirect) and automatically applies the current locale.
+If you want to interrupt the render and redirect to another page, you can invoke the `redirect` function. This wraps [the `redirect` function from Next.js](https://nextjs.org/docs/app/api-reference/functions/redirect) and localizes the pathname as necessary.
-
-
+Note that a `locale` prop is always required, even if you're just passing [the current locale](/docs/usage/configuration#locale).
```tsx
import {redirect} from '@/i18n/routing';
-// When the user is on `/en`, this will be `/en/login`
-redirect('/login');
+// Redirects to `/en/login`
+redirect({href: '/login', locale: 'en'});
-// Dynamic params need to be interpolated into the pathname
-redirect('/users/12');
+// Search params can be added via `query`
+redirect({href: '/users', query: {sortBy: 'name'}, locale: 'en'});
```
-
-
-
-When using [localized pathnames](/docs/routing#pathnames), the provided `href` corresponds to an internal pathname, but will be mapped to a locale-specific pathname.
+Depending on if you're using the pathnames setting, dynamic params can either be passed as:
```tsx
-import {redirect} from '@/i18n/routing';
+// 1. A final string (when not using `pathnames`)
+redirect({href: '/users/12', locale: 'en'});
-// When the user is on `/en`, this will be `/en/login`
-redirect('/login');
-
-// Dynamic params need to be provided as objects
+// 2. An object (when using `pathnames`)
redirect({
- pathname: '/help/[articleSlug]',
- params: {articleSlug: 'how-to-login'}
-});
-
-// Search params can be added via `query`
-redirect({
- pathname: '/users',
- query: {sortBy: 'name'}
+ href: {
+ pathname: '/users/[userId]',
+ params: {userId: '5'}
+ },
+ locale: 'en'
});
```
-
-
-
[`permanentRedirect`](https://nextjs.org/docs/app/api-reference/functions/permanentRedirect)
is supported too.
@@ -472,37 +308,51 @@ redirect({
If you need to construct a particular pathname based on a locale, you can call the `getPathname` function. This can for example be useful to retrieve a [canonical link](https://nextjs.org/docs/app/api-reference/functions/generate-metadata#alternates) for a page that accepts search params.
-
-
+```tsx
+import {getPathname} from '@/i18n/routing';
-(This API is only available for localized pathnames, since it is not necessary for shared pathnames.)
+// Will return `/en/about`
+const pathname = getPathname({
+ locale: 'en',
+ href: '/about'
+});
-
-
+// Search params can be added via `query`
+const pathname = getPathname({
+ locale: 'en',
+ href: {
+ pathname: '/users',
+ params: {sortBy: 'name'}
+ }
+});
+```
-```tsx filename="page.tsx"
-import {getPathname} from '@/i18n/routing';
+Depending on if you're using the [`pathnames`](/docs/routing#pathnames) setting, dynamic params can either be passed as:
-export async function generateMetadata({params: {locale}}) {
- // Example: This page accepts search params like `?sort=asc`.
- // A canonical link informs search engines that only the
- // version without search params should be indexed.
-
- const pathname = getPathname({
- locale,
- href: {
- pathname: '/users/[userId]',
- params: {userId: '5'}
- }
- });
-
- return {
- alternates: {
- canonical: '/' + locale + pathname
- }
- };
-}
+```tsx
+// 1. A final string (when not using `pathnames`)
+const pathname = getPathname({
+ locale: 'en',
+ href: '/users/12'
+});
+
+// 2. An object (when using `pathnames`)
+const pathname = getPathname({
+ locale: 'en',
+ href: {
+ pathname: '/users/[userId]',
+ params: {userId: '5'}
+ }
+});
```
-
-
+## Legacy APIs
+
+`next-intl@3.0.0` brought the first release of the navigation APIs with these functions:
+
+- `createSharedPathnamesNavigation`
+- `createLocalizedPathnamesNavigation`
+
+As part of `next-intl@3.21.0`, these functions have been replaced by a single `createNavigation` function, which unifies the API for both use cases and also fixes a few quirks in the previous APIs. Going forward, `createNavigation` is recommended and the previous functions will be deprecated in an upcoming release.
+
+While `createNavigation` is mostly API-compatible, there are some minor differences that should be noted. Please refer to [PR #1316](https://github.com/amannn/next-intl/pull/1316) for full details.
diff --git a/docs/pages/docs/usage/messages.mdx b/docs/pages/docs/usage/messages.mdx
index 8c1f78950..2802742a8 100644
--- a/docs/pages/docs/usage/messages.mdx
+++ b/docs/pages/docs/usage/messages.mdx
@@ -408,7 +408,7 @@ t.rich('message', {
});
```
-For the use case of localizing pathnames, consider using [`createLocalizedPathnamesNavigation`](/docs/routing/navigation).
+For the use case of localizing pathnames, consider using [`pathnames`](/docs/routing#pathnames).
diff --git a/examples/example-app-router-migration/src/i18n/routing.ts b/examples/example-app-router-migration/src/i18n/routing.ts
index 371487a7f..986738f3f 100644
--- a/examples/example-app-router-migration/src/i18n/routing.ts
+++ b/examples/example-app-router-migration/src/i18n/routing.ts
@@ -1,4 +1,4 @@
-import {createSharedPathnamesNavigation} from 'next-intl/navigation';
+import {createNavigation} from 'next-intl/navigation';
import {defineRouting} from 'next-intl/routing';
export const routing = defineRouting({
@@ -7,4 +7,4 @@ export const routing = defineRouting({
});
export const {Link, redirect, usePathname, useRouter} =
- createSharedPathnamesNavigation(routing);
+ createNavigation(routing);
diff --git a/examples/example-app-router-next-auth/src/i18n/routing.ts b/examples/example-app-router-next-auth/src/i18n/routing.ts
index 30c0fa8de..ab99b9837 100644
--- a/examples/example-app-router-next-auth/src/i18n/routing.ts
+++ b/examples/example-app-router-next-auth/src/i18n/routing.ts
@@ -1,4 +1,4 @@
-import {createSharedPathnamesNavigation} from 'next-intl/navigation';
+import {createNavigation} from 'next-intl/navigation';
import {defineRouting} from 'next-intl/routing';
export const routing = defineRouting({
@@ -8,4 +8,4 @@ export const routing = defineRouting({
});
export const {Link, redirect, usePathname, useRouter} =
- createSharedPathnamesNavigation(routing);
+ createNavigation(routing);
diff --git a/examples/example-app-router-playground/.gitignore b/examples/example-app-router-playground/.gitignore
index ef113c3c4..d61873784 100644
--- a/examples/example-app-router-playground/.gitignore
+++ b/examples/example-app-router-playground/.gitignore
@@ -4,3 +4,4 @@
tsconfig.tsbuildinfo
*storybook.log
storybook-static
+test-results
diff --git a/examples/example-app-router-playground/next.config.mjs b/examples/example-app-router-playground/next.config.mjs
index f30c3b7f9..0585df3ed 100644
--- a/examples/example-app-router-playground/next.config.mjs
+++ b/examples/example-app-router-playground/next.config.mjs
@@ -8,7 +8,8 @@ const withMdx = mdxPlugin();
export default withMdx(
withNextIntl({
- trailingSlash: process.env.TRAILING_SLASH === 'true',
+ trailingSlash: process.env.NEXT_PUBLIC_USE_CASE === 'trailing-slash',
+ basePath: process.env.NEXT_PUBLIC_USE_CASE === 'base-path' ? '/base/path' : undefined,
experimental: {
staleTimes: {
// Next.js 14.2 broke `locale-prefix-never.spec.ts`.
diff --git a/examples/example-app-router-playground/package.json b/examples/example-app-router-playground/package.json
index 26011a00f..d1bcb8d5a 100644
--- a/examples/example-app-router-playground/package.json
+++ b/examples/example-app-router-playground/package.json
@@ -4,10 +4,8 @@
"scripts": {
"dev": "next dev",
"lint": "eslint src && tsc",
- "test": "pnpm run test:playwright:main && pnpm run test:playwright:locale-prefix-never && pnpm run test:playwright:trailing-slash && pnpm run test:jest",
- "test:playwright:main": "TEST_MATCH=main.spec.ts playwright test",
- "test:playwright:locale-prefix-never": "NEXT_PUBLIC_LOCALE_PREFIX=never pnpm build && TEST_MATCH=locale-prefix-never.spec.ts playwright test",
- "test:playwright:trailing-slash": "TRAILING_SLASH=true pnpm build && TEST_MATCH=trailing-slash.spec.ts playwright test",
+ "test": "pnpm test:jest && node runPlaywright.mjs",
+ "test:playwright": "node runPlaywright.mjs",
"test:jest": "jest",
"build": "next build",
"start": "next start",
@@ -15,6 +13,7 @@
},
"dependencies": {
"@mdx-js/react": "^3.0.1",
+ "@radix-ui/react-dropdown-menu": "^2.1.1",
"lodash": "^4.17.21",
"ms": "2.1.3",
"next": "^14.2.4",
diff --git a/examples/example-app-router-playground/playwright.config.ts b/examples/example-app-router-playground/playwright.config.ts
index cdd5c90ba..e6a7e8000 100644
--- a/examples/example-app-router-playground/playwright.config.ts
+++ b/examples/example-app-router-playground/playwright.config.ts
@@ -5,6 +5,9 @@ import {devices} from '@playwright/test';
// Use a distinct port on CI to avoid conflicts during concurrent tests
const PORT = process.env.CI ? 3004 : 3000;
+// Forward to specs
+process.env.PORT = PORT.toString();
+
const config: PlaywrightTestConfig = {
retries: process.env.CI ? 1 : 0,
testMatch: process.env.TEST_MATCH || 'main.spec.ts',
diff --git a/examples/example-app-router-playground/runPlaywright.mjs b/examples/example-app-router-playground/runPlaywright.mjs
new file mode 100644
index 000000000..45779d1fc
--- /dev/null
+++ b/examples/example-app-router-playground/runPlaywright.mjs
@@ -0,0 +1,17 @@
+import {execSync} from 'child_process';
+
+const useCases = [
+ 'main',
+ 'locale-prefix-never',
+ 'trailing-slash',
+ 'base-path',
+ 'domains'
+];
+
+for (const useCase of useCases) {
+ // eslint-disable-next-line no-console
+ console.log(`Running tests for use case: ${useCase}`);
+
+ const command = `NEXT_PUBLIC_USE_CASE=${useCase} pnpm build && NEXT_PUBLIC_USE_CASE=${useCase} TEST_MATCH=${useCase}.spec.ts playwright test`;
+ execSync(command, {stdio: 'inherit'});
+}
diff --git a/examples/example-app-router-playground/src/app/[locale]/client/redirect/page.tsx b/examples/example-app-router-playground/src/app/[locale]/client/redirect/page.tsx
index 0cfe7fff9..c11a03496 100644
--- a/examples/example-app-router-playground/src/app/[locale]/client/redirect/page.tsx
+++ b/examples/example-app-router-playground/src/app/[locale]/client/redirect/page.tsx
@@ -1,7 +1,9 @@
'use client';
+import {useLocale} from 'next-intl';
import {redirect} from '@/i18n/routing';
export default function ClientRedirectPage() {
- redirect('/client');
+ const locale = useLocale();
+ redirect({href: '/client', locale});
}
diff --git a/examples/example-app-router-playground/src/app/[locale]/news/[articleId]/page.tsx b/examples/example-app-router-playground/src/app/[locale]/news/[articleId]/page.tsx
index 46c94b8ad..7596bfb44 100644
--- a/examples/example-app-router-playground/src/app/[locale]/news/[articleId]/page.tsx
+++ b/examples/example-app-router-playground/src/app/[locale]/news/[articleId]/page.tsx
@@ -1,6 +1,6 @@
import {Metadata} from 'next';
import {useTranslations} from 'next-intl';
-import {getPathname, routing, Locale} from '@/i18n/routing';
+import {getPathname, Locale} from '@/i18n/routing';
type Props = {
params: {
@@ -10,19 +10,17 @@ type Props = {
};
export async function generateMetadata({params}: Props): Promise {
- let canonical = getPathname({
- href: {
- pathname: '/news/[articleId]',
- params: {articleId: params.articleId}
- },
- locale: params.locale
- });
-
- if (params.locale !== routing.defaultLocale) {
- canonical = '/' + params.locale + canonical;
- }
-
- return {alternates: {canonical}};
+ return {
+ alternates: {
+ canonical: getPathname({
+ href: {
+ pathname: '/news/[articleId]',
+ params: {articleId: params.articleId}
+ },
+ locale: params.locale
+ })
+ }
+ };
}
export default function NewsArticle({params}: Props) {
diff --git a/examples/example-app-router-playground/src/app/[locale]/page.tsx b/examples/example-app-router-playground/src/app/[locale]/page.tsx
index 47ad4b27b..8cf1b4db2 100644
--- a/examples/example-app-router-playground/src/app/[locale]/page.tsx
+++ b/examples/example-app-router-playground/src/app/[locale]/page.tsx
@@ -11,6 +11,7 @@ import LocaleSwitcher from '../../components/LocaleSwitcher';
import PageLayout from '../../components/PageLayout';
import MessagesAsPropsCounter from '../../components/client/01-MessagesAsPropsCounter';
import MessagesOnClientCounter from '../../components/client/02-MessagesOnClientCounter';
+import DropdownMenu from '@/components/DropdownMenu';
import {Link} from '@/i18n/routing';
type Props = {
@@ -60,6 +61,7 @@ export default function Index({searchParams}: Props) {
+
);
}
diff --git a/examples/example-app-router-playground/src/app/[locale]/redirect/page.tsx b/examples/example-app-router-playground/src/app/[locale]/redirect/page.tsx
index 970f7ab2c..9adab80e8 100644
--- a/examples/example-app-router-playground/src/app/[locale]/redirect/page.tsx
+++ b/examples/example-app-router-playground/src/app/[locale]/redirect/page.tsx
@@ -1,5 +1,7 @@
+import {useLocale} from 'next-intl';
import {redirect} from '@/i18n/routing';
export default function Redirect() {
- redirect('/client');
+ const locale = useLocale();
+ redirect({href: '/client', locale});
}
diff --git a/examples/example-app-router-playground/src/components/ClientLink.tsx b/examples/example-app-router-playground/src/components/ClientLink.tsx
index 108e23321..c0f6c79b4 100644
--- a/examples/example-app-router-playground/src/components/ClientLink.tsx
+++ b/examples/example-app-router-playground/src/components/ClientLink.tsx
@@ -1,10 +1,8 @@
'use client';
import {ComponentProps} from 'react';
-import {Link, Pathnames} from '@/i18n/routing';
+import {Link} from '@/i18n/routing';
-export default function NavigationLink(
- props: ComponentProps>
-) {
+export default function NavigationLink(props: ComponentProps) {
return ;
}
diff --git a/examples/example-app-router-playground/src/components/DropdownMenu.tsx b/examples/example-app-router-playground/src/components/DropdownMenu.tsx
new file mode 100644
index 000000000..550da482a
--- /dev/null
+++ b/examples/example-app-router-playground/src/components/DropdownMenu.tsx
@@ -0,0 +1,19 @@
+import * as Dropdown from '@radix-ui/react-dropdown-menu';
+import {Link as NextIntlLink} from '@/i18n/routing';
+
+export default function DropdownMenu() {
+ return (
+
+ Toggle dropdown
+
+
+ Bar
+
+ Link to about
+
+ Foo
+
+
+
+ );
+}
diff --git a/examples/example-app-router-playground/src/components/NavigationLink.tsx b/examples/example-app-router-playground/src/components/NavigationLink.tsx
index 55d4302b0..085cc9141 100644
--- a/examples/example-app-router-playground/src/components/NavigationLink.tsx
+++ b/examples/example-app-router-playground/src/components/NavigationLink.tsx
@@ -2,12 +2,12 @@
import {useSelectedLayoutSegment} from 'next/navigation';
import {ComponentProps} from 'react';
-import {Link, Pathnames} from '@/i18n/routing';
+import {Link} from '@/i18n/routing';
-export default function NavigationLink({
+export default function NavigationLink({
href,
...rest
-}: ComponentProps>) {
+}: ComponentProps) {
const selectedLayoutSegment = useSelectedLayoutSegment();
const pathname = selectedLayoutSegment ? `/${selectedLayoutSegment}` : '/';
const isActive = pathname === href;
diff --git a/examples/example-app-router-playground/src/i18n/routing.ts b/examples/example-app-router-playground/src/i18n/routing.ts
index e58c59d49..7a9221ce4 100644
--- a/examples/example-app-router-playground/src/i18n/routing.ts
+++ b/examples/example-app-router-playground/src/i18n/routing.ts
@@ -1,11 +1,11 @@
-import {createLocalizedPathnamesNavigation} from 'next-intl/navigation';
+import {createNavigation} from 'next-intl/navigation';
import {defineRouting} from 'next-intl/routing';
export const routing = defineRouting({
locales: ['en', 'de', 'es', 'ja'],
defaultLocale: 'en',
localePrefix:
- process.env.NEXT_PUBLIC_LOCALE_PREFIX === 'never'
+ process.env.NEXT_PUBLIC_USE_CASE === 'locale-prefix-never'
? 'never'
: {
mode: 'as-needed',
@@ -13,6 +13,19 @@ export const routing = defineRouting({
es: '/spain'
}
},
+ domains:
+ process.env.NEXT_PUBLIC_USE_CASE === 'domains'
+ ? [
+ {
+ domain: 'example.com',
+ defaultLocale: 'en'
+ },
+ {
+ domain: 'example.de',
+ defaultLocale: 'de'
+ }
+ ]
+ : undefined,
pathnames: {
'/': '/',
'/client': '/client',
@@ -44,4 +57,4 @@ export type Pathnames = keyof typeof routing.pathnames;
export type Locale = (typeof routing.locales)[number];
export const {Link, getPathname, redirect, usePathname, useRouter} =
- createLocalizedPathnamesNavigation(routing);
+ createNavigation(routing);
diff --git a/examples/example-app-router-playground/src/middleware.ts b/examples/example-app-router-playground/src/middleware.ts
index 9844a0d5d..7468bf33d 100644
--- a/examples/example-app-router-playground/src/middleware.ts
+++ b/examples/example-app-router-playground/src/middleware.ts
@@ -4,6 +4,11 @@ import {routing} from './i18n/routing';
export default createMiddleware(routing);
export const config = {
- // Skip all paths that should not be internationalized
- matcher: ['/((?!_next|.*\\..*).*)']
+ matcher: [
+ // Skip all paths that should not be internationalized
+ '/((?!_next|.*\\..*).*)',
+
+ // Necessary for base path to work
+ '/'
+ ]
};
diff --git a/examples/example-app-router-playground/tests/base-path.spec.ts b/examples/example-app-router-playground/tests/base-path.spec.ts
new file mode 100644
index 000000000..502e24443
--- /dev/null
+++ b/examples/example-app-router-playground/tests/base-path.spec.ts
@@ -0,0 +1,22 @@
+import {test as it, expect} from '@playwright/test';
+import {assertLocaleCookieValue} from './utils';
+
+it('can use the router', async ({page}) => {
+ await page.goto('/base/path');
+ await assertLocaleCookieValue(page, 'en', {path: '/base/path'});
+
+ await page.getByRole('button', {name: 'Go to nested page'}).click();
+ await expect(page).toHaveURL('/base/path/nested');
+ await page.getByRole('link', {name: 'Home'}).click();
+ await page.getByRole('link', {name: 'Switch to German'}).click();
+
+ await expect(page).toHaveURL('/base/path/de');
+ assertLocaleCookieValue(page, 'de', {path: '/base/path'});
+ await page.getByRole('button', {name: 'Go to nested page'}).click();
+ await expect(page).toHaveURL('/base/path/de/verschachtelt');
+ await page.getByRole('link', {name: 'Start'}).click();
+ await page.getByRole('link', {name: 'Zu Englisch wechseln'}).click();
+
+ await expect(page).toHaveURL('/base/path');
+ assertLocaleCookieValue(page, 'en', {path: '/base/path'});
+});
diff --git a/examples/example-app-router-playground/tests/domains.spec.ts b/examples/example-app-router-playground/tests/domains.spec.ts
new file mode 100644
index 000000000..f1aac49dd
--- /dev/null
+++ b/examples/example-app-router-playground/tests/domains.spec.ts
@@ -0,0 +1,42 @@
+import {test as it, expect, chromium} from '@playwright/test';
+
+it('can use config based on the default locale on an unknown domain', async ({
+ page
+}) => {
+ await page.goto('/');
+ await expect(page.getByRole('heading', {name: 'Home'})).toBeVisible();
+ await expect(page).toHaveURL('/');
+ await page.getByRole('link', {name: 'Client page'}).click();
+ await expect(page).toHaveURL('/client');
+
+ await page.goto('/');
+ await page.getByRole('link', {name: 'Switch to German'}).click();
+ await expect(page).toHaveURL('/de');
+});
+
+it('can use a secondary locale unprefixed if the domain has specified it as the default locale', async () => {
+ const browser = await chromium.launch({
+ args: ['--host-resolver-rules=MAP example.de 127.0.0.1:' + process.env.PORT]
+ });
+
+ const page = await browser.newPage();
+ await page.route('**/*', (route) =>
+ route.continue({
+ headers: {
+ 'accept-language': 'de',
+ 'x-forwarded-port': '80' // (playwright default)
+ }
+ })
+ );
+
+ await page.goto('http://example.de');
+ await expect(page).toHaveURL('http://example.de'); // (no redirect)
+ await expect(page.getByRole('heading', {name: 'Start'})).toBeVisible();
+ await page.getByRole('link', {name: 'Client-Seite'}).click();
+ await expect(page).toHaveURL('http://example.de/client');
+ await page.getByRole('link', {name: 'Start'}).click();
+ await expect(page).toHaveURL('http://example.de');
+ await page.getByRole('link', {name: 'Zu Englisch wechseln'}).click();
+ await expect(page).toHaveURL('http://example.de/en');
+ await expect(page.getByRole('heading', {name: 'Home'})).toBeVisible();
+});
diff --git a/examples/example-app-router-playground/tests/getAlternateLinks.ts b/examples/example-app-router-playground/tests/getAlternateLinks.ts
deleted file mode 100644
index 0b9c58d37..000000000
--- a/examples/example-app-router-playground/tests/getAlternateLinks.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import {APIResponse} from '@playwright/test';
-
-export default async function getAlternateLinks(response: APIResponse) {
- return (
- response
- .headers()
- .link.split(', ')
- // On CI, Playwright uses a different host somehow
- .map((cur) => cur.replace(/0\.0\.0\.0/g, 'localhost'))
- // Normalize ports
- .map((cur) => cur.replace(/localhost:\d{4}/g, 'localhost:3000'))
- );
-}
diff --git a/examples/example-app-router-playground/tests/main.spec.ts b/examples/example-app-router-playground/tests/main.spec.ts
index e32eb46c4..c62eb3a7b 100644
--- a/examples/example-app-router-playground/tests/main.spec.ts
+++ b/examples/example-app-router-playground/tests/main.spec.ts
@@ -1,23 +1,8 @@
-import {test as it, expect, Page, BrowserContext} from '@playwright/test';
-import getAlternateLinks from './getAlternateLinks';
+import {test as it, expect, BrowserContext} from '@playwright/test';
+import {getAlternateLinks, assertLocaleCookieValue} from './utils';
const describe = it.describe;
-async function assertLocaleCookieValue(
- page: Page,
- value: string,
- otherProps?: Record
-) {
- const cookie = (await page.context().cookies()).find(
- (cur) => cur.name === 'NEXT_LOCALE'
- );
- expect(cookie).toMatchObject({
- name: 'NEXT_LOCALE',
- value,
- ...otherProps
- });
-}
-
function getPageLoadTracker(context: BrowserContext) {
const state = {numPageLoads: 0};
@@ -697,6 +682,14 @@ it('can use `getPahname` to define a canonical link', async ({page}) => {
await expect(getCanonicalPathname()).resolves.toBe('/de/neuigkeiten/3');
});
+it('provides a `Link` that works with Radix Primitives', async ({page}) => {
+ await page.goto('/');
+ await page.getByRole('button', {name: 'Toggle dropdown'}).click();
+ await page.keyboard.press('ArrowDown');
+ await page.keyboard.press('ArrowDown');
+ await expect(page.getByText('Link to about')).toBeFocused();
+});
+
describe('server actions', () => {
it('can use `getTranslations` in server actions', async ({page}) => {
await page.goto('/actions');
diff --git a/examples/example-app-router-playground/tests/trailing-slash.spec.ts b/examples/example-app-router-playground/tests/trailing-slash.spec.ts
index f1cc5458b..cdcea2132 100644
--- a/examples/example-app-router-playground/tests/trailing-slash.spec.ts
+++ b/examples/example-app-router-playground/tests/trailing-slash.spec.ts
@@ -1,5 +1,5 @@
import {test as it, expect} from '@playwright/test';
-import getAlternateLinks from './getAlternateLinks';
+import {getAlternateLinks} from './utils';
it('redirects to a locale prefix correctly', async ({request}) => {
const response = await request.get('/', {
diff --git a/examples/example-app-router-playground/tests/utils.ts b/examples/example-app-router-playground/tests/utils.ts
new file mode 100644
index 000000000..259b175bf
--- /dev/null
+++ b/examples/example-app-router-playground/tests/utils.ts
@@ -0,0 +1,28 @@
+import {APIResponse, expect, Page} from '@playwright/test';
+
+export async function getAlternateLinks(response: APIResponse) {
+ return (
+ response
+ .headers()
+ .link.split(', ')
+ // On CI, Playwright uses a different host somehow
+ .map((cur) => cur.replace(/0\.0\.0\.0/g, 'localhost'))
+ // Normalize ports
+ .map((cur) => cur.replace(/localhost:\d{4}/g, 'localhost:3000'))
+ );
+}
+
+export async function assertLocaleCookieValue(
+ page: Page,
+ value: string,
+ otherProps?: Record
+) {
+ const cookie = (await page.context().cookies()).find(
+ (cur) => cur.name === 'NEXT_LOCALE'
+ );
+ expect(cookie).toMatchObject({
+ name: 'NEXT_LOCALE',
+ value,
+ ...otherProps
+ });
+}
diff --git a/examples/example-app-router/src/app/sitemap.ts b/examples/example-app-router/src/app/sitemap.ts
index 7ae7ca2eb..0fefe33a2 100644
--- a/examples/example-app-router/src/app/sitemap.ts
+++ b/examples/example-app-router/src/app/sitemap.ts
@@ -21,5 +21,5 @@ function getEntry(href: Href) {
function getUrl(href: Href, locale: Locale) {
const pathname = getPathname({locale, href});
- return `${host}/${locale}${pathname === '/' ? '' : pathname}`;
+ return host + pathname;
}
diff --git a/examples/example-app-router/src/components/NavigationLink.tsx b/examples/example-app-router/src/components/NavigationLink.tsx
index 5a25b474d..b0db7e50f 100644
--- a/examples/example-app-router/src/components/NavigationLink.tsx
+++ b/examples/example-app-router/src/components/NavigationLink.tsx
@@ -3,12 +3,12 @@
import clsx from 'clsx';
import {useSelectedLayoutSegment} from 'next/navigation';
import {ComponentProps} from 'react';
-import {Link, Pathnames} from '@/i18n/routing';
+import {Link} from '@/i18n/routing';
-export default function NavigationLink({
+export default function NavigationLink({
href,
...rest
-}: ComponentProps>) {
+}: ComponentProps) {
const selectedLayoutSegment = useSelectedLayoutSegment();
const pathname = selectedLayoutSegment ? `/${selectedLayoutSegment}` : '/';
const isActive = pathname === href;
diff --git a/examples/example-app-router/src/config.ts b/examples/example-app-router/src/config.ts
index 6c36de5f5..53d3b1d35 100644
--- a/examples/example-app-router/src/config.ts
+++ b/examples/example-app-router/src/config.ts
@@ -1,4 +1,4 @@
export const port = process.env.PORT || 3000;
-export const host = process.env.VERCEL_URL
- ? `https://${process.env.VERCEL_URL}`
+export const host = process.env.VERCEL_PROJECT_PRODUCTION_URL
+ ? `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`
: `http://localhost:${port}`;
diff --git a/examples/example-app-router/src/i18n/routing.ts b/examples/example-app-router/src/i18n/routing.ts
index 8026e3433..001b856ff 100644
--- a/examples/example-app-router/src/i18n/routing.ts
+++ b/examples/example-app-router/src/i18n/routing.ts
@@ -1,4 +1,4 @@
-import {createLocalizedPathnamesNavigation} from 'next-intl/navigation';
+import {createNavigation} from 'next-intl/navigation';
import {defineRouting} from 'next-intl/routing';
export const routing = defineRouting({
@@ -17,4 +17,4 @@ export type Pathnames = keyof typeof routing.pathnames;
export type Locale = (typeof routing.locales)[number];
export const {Link, getPathname, redirect, usePathname, useRouter} =
- createLocalizedPathnamesNavigation(routing);
+ createNavigation(routing);
diff --git a/packages/next-intl/.eslintrc.js b/packages/next-intl/.eslintrc.js
index 46c1bde5e..bd87e835f 100644
--- a/packages/next-intl/.eslintrc.js
+++ b/packages/next-intl/.eslintrc.js
@@ -8,7 +8,8 @@ module.exports = {
plugins: ['deprecation', 'eslint-plugin-react-compiler'],
rules: {
'import/no-useless-path-segments': 'error',
- 'react-compiler/react-compiler': 'error'
+ 'react-compiler/react-compiler': 'error',
+ '@typescript-eslint/ban-types': 'off'
},
overrides: [
{
diff --git a/packages/next-intl/.size-limit.ts b/packages/next-intl/.size-limit.ts
index 64c393d9f..3e357fb49 100644
--- a/packages/next-intl/.size-limit.ts
+++ b/packages/next-intl/.size-limit.ts
@@ -2,43 +2,79 @@ import type {SizeLimitConfig} from 'size-limit';
const config: SizeLimitConfig = [
{
+ name: 'import * from \'next-intl\' (react-client)',
path: 'dist/production/index.react-client.js',
limit: '14.095 KB'
},
{
+ name: 'import * from \'next-intl\' (react-server)',
path: 'dist/production/index.react-server.js',
limit: '14.665 KB'
},
{
+ name: 'import {createSharedPathnamesNavigation} from \'next-intl/navigation\' (react-client)',
path: 'dist/production/navigation.react-client.js',
- limit: '3.155 KB'
+ import: '{createSharedPathnamesNavigation}',
+ limit: '3.885 KB'
},
{
+ name: 'import {createLocalizedPathnamesNavigation} from \'next-intl/navigation\' (react-client)',
+ path: 'dist/production/navigation.react-client.js',
+ import: '{createLocalizedPathnamesNavigation}',
+ limit: '3.885 KB'
+ },
+ {
+ name: 'import {createNavigation} from \'next-intl/navigation\' (react-client)',
+ path: 'dist/production/navigation.react-client.js',
+ import: '{createNavigation}',
+ limit: '3.885 KB'
+ },
+ {
+ name: 'import {createSharedPathnamesNavigation} from \'next-intl/navigation\' (react-server)',
+ path: 'dist/production/navigation.react-server.js',
+ import: '{createSharedPathnamesNavigation}',
+ limit: '16.515 KB'
+ },
+ {
+ name: 'import {createLocalizedPathnamesNavigation} from \'next-intl/navigation\' (react-server)',
+ path: 'dist/production/navigation.react-server.js',
+ import: '{createLocalizedPathnamesNavigation}',
+ limit: '16.545 KB'
+ },
+ {
+ name: 'import {createNavigation} from \'next-intl/navigation\' (react-server)',
path: 'dist/production/navigation.react-server.js',
- limit: '15.845 KB'
+ import: '{createNavigation}',
+ limit: '16.495 KB'
},
{
+ name: 'import * from \'next-intl/server\' (react-client)',
path: 'dist/production/server.react-client.js',
limit: '1 KB'
},
{
+ name: 'import * from \'next-intl/server\' (react-server)',
path: 'dist/production/server.react-server.js',
limit: '13.865 KB'
},
{
+ name: 'import createMiddleware from \'next-intl/middleware\'',
path: 'dist/production/middleware.js',
- limit: '9.625 KB'
+ limit: '9.63 KB'
},
{
+ name: 'import * from \'next-intl/routing\'',
path: 'dist/production/routing.js',
limit: '1 KB'
},
{
+ name: 'import * from \'next-intl\' (react-client, ESM)',
path: 'dist/esm/index.react-client.js',
import: '*',
limit: '14.265 kB'
},
{
+ name: 'import {NextIntlProvider} from \'next-intl\' (react-client, ESM)',
path: 'dist/esm/index.react-client.js',
import: '{NextIntlClientProvider}',
limit: '1.425 kB'
diff --git a/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.tsx b/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.tsx
index e09f4d64e..2c14439ad 100644
--- a/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.tsx
+++ b/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.tsx
@@ -1,6 +1,11 @@
import {NextRequest} from 'next/server';
import {ResolvedRoutingConfig} from '../routing/config';
-import {Locales, Pathnames} from '../routing/types';
+import {
+ DomainsConfig,
+ LocalePrefixMode,
+ Locales,
+ Pathnames
+} from '../routing/types';
import {normalizeTrailingSlash} from '../shared/utils';
import {
applyBasePath,
@@ -16,14 +21,24 @@ import {
*/
export default function getAlternateLinksHeaderValue<
AppLocales extends Locales,
- AppPathnames extends Pathnames = never
+ AppLocalePrefixMode extends LocalePrefixMode,
+ AppPathnames extends Pathnames | undefined,
+ AppDomains extends DomainsConfig | undefined
>({
localizedPathnames,
request,
resolvedLocale,
routing
}: {
- routing: ResolvedRoutingConfig;
+ routing: Omit<
+ ResolvedRoutingConfig<
+ AppLocales,
+ AppLocalePrefixMode,
+ AppPathnames,
+ AppDomains
+ >,
+ 'pathnames'
+ >;
request: NextRequest;
resolvedLocale: AppLocales[number];
localizedPathnames?: Pathnames[string];
diff --git a/packages/next-intl/src/middleware/middleware.test.tsx b/packages/next-intl/src/middleware/middleware.test.tsx
index 19bab9559..18660b50e 100644
--- a/packages/next-intl/src/middleware/middleware.test.tsx
+++ b/packages/next-intl/src/middleware/middleware.test.tsx
@@ -1506,7 +1506,7 @@ describe('prefix-based routing', () => {
'renders a localized pathname where the internal pathname was defined with a trailing slash',
(pathname) => {
createMiddleware({
- defaultLocale: 'en',
+ defaultLocale: 'de',
locales: ['de'],
localePrefix: 'always',
pathnames: {
@@ -1526,7 +1526,7 @@ describe('prefix-based routing', () => {
'redirects a localized pathname where the internal pathname was defined with a trailing slash',
(pathname) => {
createMiddleware({
- defaultLocale: 'en',
+ defaultLocale: 'de',
locales: ['de'],
localePrefix: 'always',
pathnames: {
diff --git a/packages/next-intl/src/middleware/middleware.tsx b/packages/next-intl/src/middleware/middleware.tsx
index bebf0db27..8b0bb2030 100644
--- a/packages/next-intl/src/middleware/middleware.tsx
+++ b/packages/next-intl/src/middleware/middleware.tsx
@@ -1,6 +1,11 @@
import {NextRequest, NextResponse} from 'next/server';
import {receiveRoutingConfig, RoutingConfig} from '../routing/config';
-import {Locales, Pathnames} from '../routing/types';
+import {
+ DomainsConfig,
+ LocalePrefixMode,
+ Locales,
+ Pathnames
+} from '../routing/types';
import {HEADER_LOCALE_NAME} from '../shared/constants';
import {
getLocalePrefix,
@@ -26,9 +31,16 @@ import {
export default function createMiddleware<
AppLocales extends Locales,
- AppPathnames extends Pathnames = never
+ AppLocalePrefixMode extends LocalePrefixMode = 'always',
+ AppPathnames extends Pathnames = never,
+ AppDomains extends DomainsConfig = never
>(
- routing: RoutingConfig &
+ routing: RoutingConfig<
+ AppLocales,
+ AppLocalePrefixMode,
+ AppPathnames,
+ AppDomains
+ > &
// Convenience if `routing` is generated dynamically (i.e. without `defineRouting`)
MiddlewareOptions,
options?: MiddlewareOptions
@@ -156,16 +168,19 @@ export default function createMiddleware<
let internalTemplateName: keyof AppPathnames | undefined;
let unprefixedInternalPathname = unprefixedExternalPathname;
- if ('pathnames' in resolvedRouting) {
+ const pathnames = (resolvedRouting as any).pathnames as
+ | AppPathnames
+ | undefined;
+ if (pathnames) {
let resolvedTemplateLocale: AppLocales[number] | undefined;
[resolvedTemplateLocale, internalTemplateName] = getInternalTemplate(
- resolvedRouting.pathnames,
+ pathnames,
unprefixedExternalPathname,
locale
);
if (internalTemplateName) {
- const pathnameConfig = resolvedRouting.pathnames[internalTemplateName];
+ const pathnameConfig = pathnames[internalTemplateName];
const localeTemplate: string =
typeof pathnameConfig === 'string'
? pathnameConfig
@@ -310,8 +325,8 @@ export default function createMiddleware<
getAlternateLinksHeaderValue({
routing: resolvedRouting,
localizedPathnames:
- internalTemplateName! != null && 'pathnames' in resolvedRouting
- ? resolvedRouting.pathnames?.[internalTemplateName]
+ internalTemplateName! != null && pathnames
+ ? pathnames?.[internalTemplateName]
: undefined,
request,
resolvedLocale: locale
diff --git a/packages/next-intl/src/middleware/resolveLocale.tsx b/packages/next-intl/src/middleware/resolveLocale.tsx
index d829dc344..063237ded 100644
--- a/packages/next-intl/src/middleware/resolveLocale.tsx
+++ b/packages/next-intl/src/middleware/resolveLocale.tsx
@@ -6,7 +6,8 @@ import {
Locales,
Pathnames,
DomainsConfig,
- DomainConfig
+ DomainConfig,
+ LocalePrefixMode
} from '../routing/types';
import {COOKIE_LOCALE_NAME} from '../shared/constants';
import {ResolvedMiddlewareOptions} from './config';
@@ -71,13 +72,23 @@ function getLocaleFromCookie(
function resolveLocaleFromPrefix<
AppLocales extends Locales,
- AppPathnames extends Pathnames = never
+ AppLocalePrefixMode extends LocalePrefixMode,
+ AppPathnames extends Pathnames | undefined,
+ AppDomains extends DomainsConfig | undefined
>(
{
defaultLocale,
localePrefix,
locales
- }: ResolvedRoutingConfig,
+ }: Omit<
+ ResolvedRoutingConfig<
+ AppLocales,
+ AppLocalePrefixMode,
+ AppPathnames,
+ AppDomains
+ >,
+ 'pathnames'
+ >,
{localeDetection}: ResolvedMiddlewareOptions,
requestHeaders: Headers,
requestCookies: RequestCookies,
@@ -110,10 +121,19 @@ function resolveLocaleFromPrefix<
function resolveLocaleFromDomain<
AppLocales extends Locales,
- AppPathnames extends Pathnames = never
+ AppLocalePrefixMode extends LocalePrefixMode,
+ AppPathnames extends Pathnames | undefined,
+ AppDomains extends DomainsConfig | undefined
>(
- routing: Omit, 'domains'> &
- Required, 'domains'>>,
+ routing: Omit<
+ ResolvedRoutingConfig<
+ AppLocales,
+ AppLocalePrefixMode,
+ AppPathnames,
+ AppDomains
+ >,
+ 'pathnames'
+ >,
options: ResolvedMiddlewareOptions,
requestHeaders: Headers,
requestCookies: RequestCookies,
@@ -188,24 +208,27 @@ function resolveLocaleFromDomain<
export default function resolveLocale<
AppLocales extends Locales,
- AppPathnames extends Pathnames = never
+ AppLocalePrefixMode extends LocalePrefixMode,
+ AppPathnames extends Pathnames | undefined,
+ AppDomains extends DomainsConfig | undefined
>(
- routing: ResolvedRoutingConfig,
+ routing: Omit<
+ ResolvedRoutingConfig<
+ AppLocales,
+ AppLocalePrefixMode,
+ AppPathnames,
+ AppDomains
+ >,
+ 'pathnames'
+ >,
options: ResolvedMiddlewareOptions,
requestHeaders: Headers,
requestCookies: RequestCookies,
pathname: string
): {locale: AppLocales[number]; domain?: DomainConfig} {
if (routing.domains) {
- const routingWithDomains = routing as Omit<
- ResolvedRoutingConfig,
- 'domains'
- > &
- Required<
- Pick, 'domains'>
- >;
return resolveLocaleFromDomain(
- routingWithDomains,
+ routing,
options,
requestHeaders,
requestCookies,
diff --git a/packages/next-intl/src/middleware/utils.tsx b/packages/next-intl/src/middleware/utils.tsx
index 6b7da0234..78c7fe9c9 100644
--- a/packages/next-intl/src/middleware/utils.tsx
+++ b/packages/next-intl/src/middleware/utils.tsx
@@ -3,7 +3,8 @@ import {
LocalePrefixConfigVerbose,
DomainConfig,
Pathnames,
- DomainsConfig
+ DomainsConfig,
+ LocalePrefixMode
} from '../routing/types';
import {
getLocalePrefix,
@@ -92,10 +93,13 @@ export function formatTemplatePathname(
/**
* Removes potential prefixes from the pathname.
*/
-export function getNormalizedPathname(
+export function getNormalizedPathname<
+ AppLocales extends Locales,
+ AppLocalePrefixMode extends LocalePrefixMode
+>(
pathname: string,
locales: AppLocales,
- localePrefix: LocalePrefixConfigVerbose
+ localePrefix: LocalePrefixConfigVerbose
) {
// Add trailing slash for consistent handling
// both for the root as well as nested paths
@@ -127,9 +131,12 @@ export function findCaseInsensitiveString(
return strings.find((cur) => cur.toLowerCase() === candidate.toLowerCase());
}
-export function getLocalePrefixes(
+export function getLocalePrefixes<
+ AppLocales extends Locales,
+ AppLocalePrefixMode extends LocalePrefixMode
+>(
locales: AppLocales,
- localePrefix: LocalePrefixConfigVerbose,
+ localePrefix: LocalePrefixConfigVerbose,
sort = true
): Array<[AppLocales[number], string]> {
const prefixes = locales.map((locale) => [
@@ -145,10 +152,13 @@ export function getLocalePrefixes(
return prefixes as Array<[AppLocales[number], string]>;
}
-export function getPathnameMatch(
+export function getPathnameMatch<
+ AppLocales extends Locales,
+ AppLocalePrefixMode extends LocalePrefixMode
+>(
pathname: string,
locales: AppLocales,
- localePrefix: LocalePrefixConfigVerbose
+ localePrefix: LocalePrefixConfigVerbose
):
| {
locale: AppLocales[number];
diff --git a/packages/next-intl/src/navigation/createLocalizedPathnamesNavigation.test.tsx b/packages/next-intl/src/navigation/createLocalizedPathnamesNavigation.test.tsx
index a7a6fe9c7..d1e307eb8 100644
--- a/packages/next-intl/src/navigation/createLocalizedPathnamesNavigation.test.tsx
+++ b/packages/next-intl/src/navigation/createLocalizedPathnamesNavigation.test.tsx
@@ -14,7 +14,7 @@ import {getRequestLocale} from '../server/react-server/RequestLocale';
import {getLocalePrefix} from '../shared/utils';
import createLocalizedPathnamesNavigationClient from './react-client/createLocalizedPathnamesNavigation';
import createLocalizedPathnamesNavigationServer from './react-server/createLocalizedPathnamesNavigation';
-import BaseLink from './shared/BaseLink';
+import LegacyBaseLink from './shared/LegacyBaseLink';
vi.mock('next/navigation', async () => {
const actual = await vi.importActual('next/navigation');
@@ -39,7 +39,7 @@ vi.mock('../../src/navigation/react-server/ServerLink', () => ({
const finalLocale = locale || 'en';
const prefix = getLocalePrefix(finalLocale, localePrefix);
return (
- {
+ const actual = await vi.importActual('next/navigation');
+ return {
+ ...actual,
+ useParams: vi.fn(() => ({locale: 'en'})),
+ redirect: vi.fn(),
+ permanentRedirect: vi.fn()
+ };
+});
+vi.mock('../../src/server/react-server/RequestLocale');
+
+function mockCurrentLocale(locale: string) {
+ // Enable synchronous rendering without having to suspend
+ const localePromise = Promise.resolve(locale);
+ (localePromise as any).status = 'fulfilled';
+ (localePromise as any).value = locale;
+
+ // @ts-expect-error -- Async values are allowed
+ vi.mocked(getRequestLocale).mockImplementation(() => localePromise);
+
+ vi.mocked(nextUseParams<{locale: string}>).mockImplementation(() => ({
+ locale
+ }));
+}
+
+function mockLocation(location: Partial) {
+ delete (global.window as any).location;
+ global.window ??= Object.create(window);
+ (global.window as any).location = location;
+}
+
+beforeEach(() => {
+ mockCurrentLocale('en');
+ mockLocation({host: 'localhost:3000'});
+});
+
+const locales = ['en', 'de', 'ja'] as const;
+const defaultLocale = 'en' as const;
+
+const domains = [
+ {
+ defaultLocale: 'en',
+ domain: 'example.com'
+ },
+ {
+ defaultLocale: 'de',
+ domain: 'example.de',
+ locales: ['de', 'en']
+ }
+] satisfies DomainsConfig;
+
+const pathnames = {
+ '/': '/',
+ '/about': {
+ en: '/about',
+ de: '/ueber-uns',
+ ja: '/約'
+ },
+ '/news/[articleSlug]-[articleId]': {
+ en: '/news/[articleSlug]-[articleId]',
+ de: '/neuigkeiten/[articleSlug]-[articleId]',
+ ja: '/ニュース/[articleSlug]-[articleId]'
+ },
+ '/categories/[...parts]': {
+ en: '/categories/[...parts]',
+ de: '/kategorien/[...parts]',
+ ja: '/カテゴリ/[...parts]'
+ },
+ '/catch-all/[[...parts]]': '/catch-all/[[...parts]]'
+} satisfies Pathnames;
+
+function runInRender(cb: () => void) {
+ function Component() {
+ cb();
+ return null;
+ }
+ render();
+}
+
+describe.each([
+ {
+ env: 'react-client',
+ implementation: createNavigationClient
+ },
+ {
+ env: 'react-server',
+ implementation: createNavigationServer
+ }
+])('createNavigation ($env)', ({implementation: createNavigation}) => {
+ describe("localePrefix: 'always'", () => {
+ const {Link, getPathname, permanentRedirect, redirect} = createNavigation({
+ locales,
+ defaultLocale,
+ localePrefix: 'always'
+ });
+
+ describe('Link', () => {
+ it('renders a prefix when currently on the default locale', () => {
+ const markup = renderToString(About);
+ expect(markup).toContain('href="/en/about"');
+ });
+
+ it('renders a prefix when currently on a secondary locale', () => {
+ mockCurrentLocale('de');
+ const markup = renderToString(About);
+ expect(markup).toContain('href="/de/about"');
+ });
+
+ it('accepts search params', () => {
+ const markup = renderToString(
+ About
+ );
+ expect(markup).toContain('href="/en/about?foo=bar"');
+ });
+
+ it('renders a prefix for a different locale', () => {
+ // Being able to accept a string and not only a strictly typed locale is
+ // important in order to be able to use a result from `useLocale()`.
+ // This is less relevant for `Link`, but this should be in sync across
+ // al navigation APIs (see https://github.com/amannn/next-intl/issues/1377)
+ const locale = 'de' as string;
+
+ const markup = renderToString(
+
+ Über uns
+
+ );
+ expect(markup).toContain('href="/de/about"');
+ expect(markup).toContain('hrefLang="de"');
+ });
+
+ it('renders an object href with an external host', () => {
+ render(
+
+ About
+
+ );
+ expect(
+ screen.getByRole('link', {name: 'About'}).getAttribute('href')
+ ).toBe('//www.test.de/about?foo=bar');
+ });
+
+ it('handles params', () => {
+ render(
+
+ About
+
+ );
+ expect(
+ screen.getByRole('link', {name: 'About'}).getAttribute('href')
+ ).toBe('/de/news/launch-party-3');
+ });
+
+ it('handles relative links correctly', () => {
+ const markup = renderToString(Test);
+ expect(markup).toContain('href="test"');
+ });
+
+ it('handles external links correctly', () => {
+ const markup = renderToString(
+ Test
+ );
+ expect(markup).toContain('href="https://example.com/test"');
+ });
+
+ it('does not allow to receive params', () => {
+ ;
+ });
+ });
+
+ describe('getPathname', () => {
+ it('can be called for the default locale', () => {
+ // Being able to accept a string and not only a strictly typed locale is
+ // important in order to be able to use a result from `useLocale()`.
+ // This is less relevant for `Link`, but this should be in sync across
+ // al navigation APIs (see https://github.com/amannn/next-intl/issues/1377)
+ const locale = 'en' as string;
+
+ expect(getPathname({href: '/unknown', locale})).toBe('/en/unknown');
+ });
+
+ it('can be called for a secondary locale', () => {
+ expect(getPathname({locale: 'de', href: '/about'})).toBe('/de/about');
+ });
+
+ it('can incorporate search params', () => {
+ expect(
+ getPathname({
+ href: {
+ pathname: '/about',
+ query: {foo: 'bar'}
+ },
+ locale: 'en'
+ })
+ ).toBe('/en/about?foo=bar');
+ });
+
+ it('does not accept `query` on the root', () => {
+ // eslint-disable-next-line no-unused-expressions
+ () =>
+ getPathname({
+ href: '/about',
+ locale: 'en',
+ // @ts-expect-error -- Not allowed
+ query: {foo: 'bar'}
+ });
+ });
+
+ it('does not accept `params` on href', () => {
+ // eslint-disable-next-line no-unused-expressions
+ () =>
+ getPathname({
+ href: {
+ pathname: '/users/[userId]',
+ // @ts-expect-error -- Not allowed
+ params: {userId: 3}
+ },
+ locale: 'en'
+ });
+ });
+
+ it('requires a locale', () => {
+ // Some background: This function can be used either in the `react-server`
+ // or the `react-client` environment. Since the function signature doesn't
+ // impose a limit on where it can be called (e.g. during rendering), we
+ // can't determine the current locale in the `react-client` environment.
+ // While we could theoretically retrieve the current locale in the
+ // `react-server` environment we need a shared function signature that
+ // works in either environment.
+
+ // @ts-expect-error -- Missing locale
+ // eslint-disable-next-line no-unused-expressions
+ () => getPathname({href: '/about'});
+ });
+
+ it('handles relative pathnames', () => {
+ // Not really useful, but we silently support this
+ expect(getPathname({locale: 'en', href: 'about'})).toBe('about');
+ });
+
+ it('handles external pathnames', () => {
+ // Not really useful, but we silently support this
+ expect(
+ getPathname({locale: 'en', href: 'https://example.com/about'})
+ ).toBe('https://example.com/about');
+ });
+
+ it('does not allow to pass a domain', () => {
+ // @ts-expect-error -- Domain is not supported
+ getPathname({locale: 'en', href: '/', domain: 'example.com'});
+ });
+
+ it('does not accept the _forcePrefix flag', () => {
+ getPathname(
+ {locale: 'en', href: '/'},
+ // @ts-expect-error -- Not supported
+ true
+ );
+ });
+ });
+
+ describe.each([
+ ['redirect', redirect, nextRedirect],
+ ['permanentRedirect', permanentRedirect, nextPermanentRedirect]
+ ])('%s', (_, redirectFn, nextRedirectFn) => {
+ it('can redirect for the default locale', () => {
+ const locale = 'en' as string;
+ runInRender(() => redirectFn({href: '/', locale}));
+ expect(nextRedirectFn).toHaveBeenLastCalledWith('/en');
+ });
+
+ it('forwards a redirect type', () => {
+ runInRender(() =>
+ redirectFn({href: '/', locale: 'en'}, RedirectType.push)
+ );
+ expect(nextRedirectFn).toHaveBeenLastCalledWith(
+ '/en',
+ RedirectType.push
+ );
+ });
+
+ it('can redirect to a different locale', () => {
+ runInRender(() => redirectFn({href: '/about', locale: 'de'}));
+ expect(nextRedirectFn).toHaveBeenLastCalledWith('/de/about');
+ });
+
+ it('handles relative pathnames', () => {
+ runInRender(() => redirectFn({href: 'about', locale: 'en'}));
+ expect(nextRedirectFn).toHaveBeenLastCalledWith('about');
+ });
+
+ it('handles search params', () => {
+ runInRender(() =>
+ redirectFn({
+ href: {pathname: '/about', query: {foo: 'bar'}},
+ locale: 'en'
+ })
+ );
+ expect(nextRedirectFn).toHaveBeenLastCalledWith('/en/about?foo=bar');
+ });
+
+ it('requires a locale', () => {
+ // @ts-expect-error -- Object expected
+ redirectFn('/');
+ // @ts-expect-error -- Missing locale
+ redirectFn({pathname: '/about'});
+ });
+ });
+ });
+
+ describe("localePrefix: 'always', no `locales`", () => {
+ const {Link, getPathname, permanentRedirect, redirect} = createNavigation({
+ localePrefix: 'always'
+ });
+
+ describe('createNavigation', () => {
+ it('can create navigation APIs with no arguments at all', () => {
+ createNavigation();
+ });
+
+ it('can not be used with `pathnames`', () => {
+ // @ts-expect-error -- Missing locales
+ createNavigation({pathnames});
+ });
+ });
+
+ describe('Link', () => {
+ it('renders a prefix for the current locale', () => {
+ const markup = renderToString(About);
+ expect(markup).toContain('href="/en/about"');
+ });
+
+ it('renders a prefix for a different locale', () => {
+ const markup = renderToString(
+
+ About
+
+ );
+ expect(markup).toContain('href="/zh/about"');
+ });
+ });
+
+ describe('getPathname', () => {
+ it('adds a prefix for the default locale', () => {
+ expect(getPathname({href: '/about', locale: 'en'})).toBe('/en/about');
+ });
+
+ it('adds a prefix for a secondary locale', () => {
+ expect(getPathname({href: '/about', locale: 'zh'})).toBe('/zh/about');
+ });
+ });
+
+ describe.each([
+ ['redirect', redirect, nextRedirect],
+ ['permanentRedirect', permanentRedirect, nextPermanentRedirect]
+ ])('%s', (_, redirectFn, nextRedirectFn) => {
+ it('can redirect for the default locale', () => {
+ runInRender(() => redirectFn({href: '/', locale: 'en'}));
+ expect(nextRedirectFn).toHaveBeenLastCalledWith('/en');
+ });
+ });
+ });
+
+ describe("localePrefix: 'always', with `pathnames`", () => {
+ const {Link, getPathname, permanentRedirect, redirect} = createNavigation({
+ locales,
+ defaultLocale,
+ localePrefix: 'always',
+ pathnames
+ });
+
+ describe('createNavigation', () => {
+ it('requires `locales` for `pathnames`', () => {
+ // @ts-expect-error -- Missing locales
+ createNavigation({
+ pathnames: {'/': '/'}
+ });
+ });
+
+ it('can be called with a `routing` object', () => {
+ createNavigation(
+ defineRouting({
+ locales: ['en', 'de'],
+ defaultLocale: 'en'
+ })
+ );
+ createNavigation(
+ defineRouting({
+ locales: ['en', 'de'],
+ defaultLocale: 'en',
+ pathnames: {
+ home: '/',
+ about: {
+ en: '/about',
+ de: '/ueber-uns'
+ }
+ }
+ })
+ );
+ });
+ });
+
+ describe('Link', () => {
+ it('renders a prefix when currently on the default locale', () => {
+ const markup = renderToString(About);
+ expect(markup).toContain('href="/en/about"');
+ });
+
+ it('renders a prefix when currently on a secondary locale', () => {
+ mockCurrentLocale('de');
+ const markup = renderToString(About);
+ expect(markup).toContain('href="/de/ueber-uns"');
+ });
+
+ it('renders a prefix for a different locale', () => {
+ const markup = renderToString(
+
+ Über uns
+
+ );
+ expect(markup).toContain('href="/de/ueber-uns"');
+ });
+
+ it('renders an object href', () => {
+ render(
+ About
+ );
+ expect(
+ screen.getByRole('link', {name: 'About'}).getAttribute('href')
+ ).toBe('/en/about?foo=bar');
+ });
+
+ it('handles params', () => {
+ render(
+
+ About
+
+ );
+ expect(
+ screen.getByRole('link', {name: 'About'}).getAttribute('href')
+ ).toBe('/de/neuigkeiten/launch-party-3');
+ });
+
+ it('handles relative pathnames', () => {
+ // @ts-expect-error -- Validation is still on
+ const markup = renderToString(Test);
+ expect(markup).toContain('href="test"');
+ });
+
+ it('handles unknown pathnames', () => {
+ // @ts-expect-error -- Validation is still on
+ const markup = renderToString(Test);
+ expect(markup).toContain('href="/en/test"');
+ });
+
+ it('handles external links correctly', () => {
+ const markup = renderToString(
+ // @ts-expect-error -- Validation is still on
+ Test
+ );
+ expect(markup).toContain('href="https://example.com/test"');
+ });
+
+ it('restricts invalid usage', () => {
+ // @ts-expect-error -- Unknown pathname
+ ;
+ // @ts-expect-error -- Missing params (this error is important when switching from shared pathnames to localized pathnames)
+ ;
+ });
+ });
+
+ describe('getPathname', () => {
+ it('can be called with a known pathname', () => {
+ expect(getPathname({href: '/about', locale: 'en'})).toBe('/en/about');
+ expect(
+ getPathname({
+ href: {pathname: '/about', query: {foo: 'bar'}},
+ locale: 'en'
+ })
+ ).toBe('/en/about?foo=bar');
+ });
+
+ it('can resolve a pathname with params', () => {
+ expect(
+ getPathname({
+ locale: 'en',
+ href: {
+ pathname: '/news/[articleSlug]-[articleId]',
+ params: {
+ articleId: 3,
+ articleSlug: 'launch-party'
+ },
+ query: {foo: 'bar'}
+ }
+ })
+ ).toBe('/en/news/launch-party-3?foo=bar');
+ });
+
+ it('can not be called with an arbitrary pathname', () => {
+ // @ts-expect-error -- Unknown pathname
+ expect(getPathname({locale: 'en', href: '/unknown'}))
+ // Works regardless
+ .toBe('/en/unknown');
+ });
+
+ it('handles relative pathnames', () => {
+ // @ts-expect-error -- Validation is still on
+ expect(getPathname({locale: 'en', href: 'about'})).toBe('about');
+ });
+ });
+
+ describe.each([
+ ['redirect', redirect, nextRedirect],
+ ['permanentRedirect', permanentRedirect, nextPermanentRedirect]
+ ])('%s', (_, redirectFn, nextRedirectFn) => {
+ it('can redirect for the default locale', () => {
+ runInRender(() => redirectFn({href: '/', locale: 'en'}));
+ expect(nextRedirectFn).toHaveBeenLastCalledWith('/en');
+ });
+
+ it('can redirect with params and search params', () => {
+ runInRender(() =>
+ redirectFn({
+ href: {
+ pathname: '/news/[articleSlug]-[articleId]',
+ params: {
+ articleId: 3,
+ articleSlug: 'launch-party'
+ },
+ query: {foo: 'bar'}
+ },
+ locale: 'en'
+ })
+ );
+ expect(nextRedirectFn).toHaveBeenLastCalledWith(
+ '/en/news/launch-party-3?foo=bar'
+ );
+ });
+
+ it('can not be called with an arbitrary pathname', () => {
+ // @ts-expect-error -- Unknown pathname
+ runInRender(() => redirectFn({href: '/unknown', locale: 'en'}));
+ // Works regardless
+ expect(nextRedirectFn).toHaveBeenLastCalledWith('/en/unknown');
+ });
+
+ it('forwards a redirect type', () => {
+ runInRender(() =>
+ redirectFn({href: '/', locale: 'en'}, RedirectType.push)
+ );
+ expect(nextRedirectFn).toHaveBeenLastCalledWith(
+ '/en',
+ RedirectType.push
+ );
+ });
+
+ it('can handle relative pathnames', () => {
+ // @ts-expect-error -- Validation is still on
+ runInRender(() => redirectFn({href: 'about', locale: 'en'}));
+ expect(nextRedirectFn).toHaveBeenLastCalledWith('about');
+ });
+ });
+ });
+
+ describe("localePrefix: 'as-needed'", () => {
+ const {Link, getPathname, permanentRedirect, redirect} = createNavigation({
+ locales,
+ defaultLocale,
+ localePrefix: 'as-needed'
+ });
+
+ describe('createNavigation', () => {
+ it('errors when no `defaultLocale` is set', () => {
+ expect(
+ () => void createNavigation({localePrefix: 'as-needed'})
+ ).toThrowError(
+ "`localePrefix: 'as-needed' requires a `defaultLocale`."
+ );
+ });
+ });
+
+ describe('Link', () => {
+ it('does not render a prefix when currently on the default locale', () => {
+ const markup = renderToString(About);
+ expect(markup).toContain('href="/about"');
+ });
+
+ it('renders a prefix when currently on a secondary locale', () => {
+ mockCurrentLocale('de');
+ const markup = renderToString(About);
+ expect(markup).toContain('href="/de/about"');
+ });
+
+ it('renders a prefix for a different locale', () => {
+ const markup = renderToString(
+
+ Über uns
+
+ );
+ expect(markup).toContain('href="/de/about"');
+ });
+
+ it('renders a prefix when currently on a secondary locale and linking to the default locale', () => {
+ mockCurrentLocale('de');
+ const markup = renderToString(
+
+ About
+
+ );
+ expect(markup).toContain('href="/en/about"');
+ });
+
+ it('renders an object href', () => {
+ render(
+ About
+ );
+ expect(
+ screen.getByRole('link', {name: 'About'}).getAttribute('href')
+ ).toBe('/about?foo=bar');
+ });
+
+ it('handles params', () => {
+ render(
+
+ About
+
+ );
+ expect(
+ screen.getByRole('link', {name: 'About'}).getAttribute('href')
+ ).toBe('/de/news/launch-party-3');
+ });
+
+ it('handles relative links correctly on the initial render', () => {
+ const markup = renderToString(Test);
+ expect(markup).toContain('href="test"');
+ });
+
+ it('does not accept `params`', () => {
+ ;
+ });
+ });
+
+ describe('getPathname', () => {
+ it('does not add a prefix for the default locale', () => {
+ expect(getPathname({locale: 'en', href: '/about'})).toBe('/about');
+ });
+
+ it('adds a prefix for a secondary locale', () => {
+ expect(getPathname({locale: 'de', href: '/about'})).toBe('/de/about');
+ });
+
+ it('requires a locale', () => {
+ // @ts-expect-error -- Missing locale
+ // eslint-disable-next-line no-unused-expressions
+ () => getPathname({href: '/about'});
+ });
+ });
+
+ describe.each([
+ ['redirect', redirect, nextRedirect],
+ ['permanentRedirect', permanentRedirect, nextPermanentRedirect]
+ ])('%s', (_, redirectFn, nextRedirectFn) => {
+ it('does not add a prefix when redirecting within the default locale', () => {
+ runInRender(() => redirectFn({href: '/', locale: 'en'}));
+ expect(nextRedirectFn).toHaveBeenLastCalledWith('/');
+ });
+
+ it('adds a prefix for a secondary locale', () => {
+ runInRender(() => redirectFn({href: '/', locale: 'de'}));
+ expect(nextRedirectFn).toHaveBeenLastCalledWith('/de');
+ });
+
+ it('forwards a redirect type', () => {
+ runInRender(() =>
+ redirectFn({href: '/', locale: 'en'}, RedirectType.push)
+ );
+ expect(nextRedirectFn).toHaveBeenLastCalledWith('/', RedirectType.push);
+ });
+ });
+ });
+
+ describe('localePrefix: "always", with `prefixes`', () => {
+ const {Link, getPathname, permanentRedirect, redirect} = createNavigation({
+ locales,
+ defaultLocale,
+ domains,
+ localePrefix: {
+ mode: 'always',
+ prefixes: {
+ en: '/us/en',
+ de: '/eu/de'
+ // (use /ja as-is)
+ }
+ }
+ });
+
+ describe('Link', () => {
+ it('renders a prefix during SSR', () => {
+ const markup = renderToString(About);
+ expect(markup).toContain('href="/us/en/about"');
+ });
+
+ it('renders a prefix when currently on a secondary locale', () => {
+ mockCurrentLocale('de');
+ render(About);
+ expect(
+ screen.getByRole('link', {name: 'About'}).getAttribute('href')
+ ).toBe('/eu/de/about');
+ });
+ });
+
+ describe('getPathname', () => {
+ it('adds a prefix for the default locale', () => {
+ expect(getPathname({locale: 'en', href: '/about'})).toBe(
+ '/us/en/about'
+ );
+ });
+
+ it('adds a prefix for a secondary locale', () => {
+ expect(getPathname({locale: 'de', href: '/about'})).toBe(
+ '/eu/de/about'
+ );
+ });
+ });
+
+ describe.each([
+ ['redirect', redirect, nextRedirect],
+ ['permanentRedirect', permanentRedirect, nextPermanentRedirect]
+ ])('%s', (_, redirectFn, nextRedirectFn) => {
+ it('adds a prefix for the default locale', () => {
+ runInRender(() => redirectFn({href: '/', locale: 'en'}));
+ expect(nextRedirectFn).toHaveBeenLastCalledWith('/us/en');
+ });
+
+ it('adds a prefix for a secondary locale', () => {
+ runInRender(() => redirectFn({href: '/about', locale: 'de'}));
+ expect(nextRedirectFn).toHaveBeenLastCalledWith('/eu/de/about');
+ });
+ });
+ });
+
+ describe("localePrefix: 'always', with `domains`", () => {
+ const {Link, getPathname, permanentRedirect, redirect} = createNavigation({
+ locales,
+ defaultLocale,
+ domains,
+ localePrefix: 'always'
+ });
+
+ describe('Link', () => {
+ it('renders a prefix during SSR', () => {
+ const markup = renderToString(About);
+ expect(markup).toContain('href="/en/about"');
+ });
+
+ it('renders a prefix eventually on the client side', () => {
+ mockLocation({host: 'example.com'});
+ render(About);
+ expect(
+ screen.getByRole('link', {name: 'About'}).getAttribute('href')
+ ).toBe('/en/about');
+ });
+ });
+
+ describe('getPathname', () => {
+ it('adds a prefix for the default locale without printing a warning', () => {
+ const consoleSpy = vi.spyOn(console, 'error');
+ expect(getPathname({locale: 'en', href: '/about'})).toBe('/en/about');
+ expect(consoleSpy).not.toHaveBeenCalled();
+ consoleSpy.mockRestore();
+ });
+ });
+
+ describe.each([
+ ['redirect', redirect, nextRedirect],
+ ['permanentRedirect', permanentRedirect, nextPermanentRedirect]
+ ])('%s', (_, redirectFn, nextRedirectFn) => {
+ it('adds a prefix for the default locale', () => {
+ runInRender(() => redirectFn({href: '/', locale: 'en'}));
+ expect(nextRedirectFn).toHaveBeenLastCalledWith('/en');
+ });
+
+ it('does not allow passing a domain', () => {
+ runInRender(() =>
+ redirectFn({
+ href: '/',
+ locale: 'en',
+ // @ts-expect-error -- Domain is not allowed
+ domain: 'example.com'
+ })
+ );
+ expect(nextRedirectFn).toHaveBeenLastCalledWith('/en');
+ });
+ });
+ });
+
+ describe("localePrefix: 'as-needed', with `domains`", () => {
+ const {Link, getPathname, permanentRedirect, redirect} = createNavigation({
+ locales,
+ defaultLocale,
+ domains,
+ localePrefix: 'as-needed'
+ });
+
+ describe('Link', () => {
+ it('renders a prefix during SSR even for the default locale', () => {
+ // (see comment in source for reasoning)
+ const markup = renderToString(About);
+ expect(markup).toContain('href="/en/about"');
+ });
+
+ it('does not render a prefix eventually on the client side for the default locale of the given domain', () => {
+ mockLocation({host: 'example.com'});
+ render(About);
+ expect(
+ screen.getByRole('link', {name: 'About'}).getAttribute('href')
+ ).toBe('/about');
+ });
+
+ it('renders a prefix when when linking to a secondary locale on an unknown domain', () => {
+ mockLocation({host: 'localhost:3000'});
+ render(
+
+ Über uns
+
+ );
+ expect(
+ screen.getByRole('link', {name: 'Über uns'}).getAttribute('href')
+ ).toBe('/de/about');
+ });
+
+ it('renders a prefix when currently on a secondary locale', () => {
+ mockLocation({host: 'example.de'});
+ mockCurrentLocale('en');
+ render(About);
+ expect(
+ screen.getByRole('link', {name: 'About'}).getAttribute('href')
+ ).toBe('/en/about');
+ });
+
+ it('does not render a prefix when currently on a domain with a different default locale', () => {
+ mockLocation({host: 'example.de'});
+ mockCurrentLocale('de');
+ render(About);
+ expect(
+ screen.getByRole('link', {name: 'About'}).getAttribute('href')
+ ).toBe('/about');
+ });
+
+ it('renders a prefix when currently on a secondary locale and linking to the default locale', () => {
+ mockLocation({host: 'example.de'});
+ mockCurrentLocale('en');
+ const markup = renderToString(
+
+ About
+
+ );
+ expect(markup).toContain('href="/de/about"');
+ });
+ });
+
+ describe('getPathname', () => {
+ it('does not add a prefix for the default locale', () => {
+ expect(
+ getPathname({locale: 'en', href: '/about', domain: 'example.com'})
+ ).toBe('/about');
+ expect(
+ getPathname({locale: 'de', href: '/about', domain: 'example.de'})
+ ).toBe('/about');
+ });
+
+ it('adds a prefix for a secondary locale', () => {
+ expect(
+ getPathname({locale: 'de', href: '/about', domain: 'example.com'})
+ ).toBe('/de/about');
+ expect(
+ getPathname({locale: 'en', href: '/about', domain: 'example.de'})
+ ).toBe('/en/about');
+ });
+
+ it('prints a warning when no domain is provided', () => {
+ const consoleSpy = vi.spyOn(console, 'error');
+ // @ts-expect-error -- Domain is not provided
+ getPathname({locale: 'de', href: '/about'});
+ expect(consoleSpy).toHaveBeenCalled();
+ consoleSpy.mockRestore();
+ });
+ });
+
+ describe.each([
+ ['redirect', redirect, nextRedirect],
+ ['permanentRedirect', permanentRedirect, nextPermanentRedirect]
+ ])('%s', (_, redirectFn, nextRedirectFn) => {
+ it('adds a prefix even for the default locale', () => {
+ // (see comment in source for reasoning)
+ runInRender(() => redirectFn({href: '/', locale: 'en'}));
+ expect(nextRedirectFn).toHaveBeenLastCalledWith('/en');
+ });
+
+ it('does not add a prefix when domain is provided for the default locale', () => {
+ runInRender(() =>
+ redirectFn({href: '/', locale: 'en', domain: 'example.com'})
+ );
+ expect(nextRedirectFn).toHaveBeenLastCalledWith('/');
+ });
+
+ it('adds a prefix for a secondary locale', () => {
+ runInRender(() => redirectFn({href: '/', locale: 'de'}));
+ expect(nextRedirectFn).toHaveBeenLastCalledWith('/de');
+ });
+ });
+ });
+
+ describe("localePrefix: 'never'", () => {
+ const {Link, getPathname, permanentRedirect, redirect} = createNavigation({
+ locales,
+ defaultLocale,
+ localePrefix: 'never'
+ });
+
+ describe('Link', () => {
+ it('renders no prefix when currently on the default locale', () => {
+ const markup = renderToString(About);
+ expect(markup).toContain('href="/about"');
+ });
+
+ it('renders no prefix when currently on a secondary locale', () => {
+ mockCurrentLocale('de');
+ const markup = renderToString(About);
+ expect(markup).toContain('href="/about"');
+ });
+
+ it('renders a prefix when linking to a secondary locale', () => {
+ const markup = renderToString(
+
+ Über uns
+
+ );
+ expect(markup).toContain('href="/de/about"');
+ expect(markup).toContain('hrefLang="de"');
+ });
+
+ it('renders a prefix when currently on a secondary locale and linking to the default locale', () => {
+ mockCurrentLocale('de');
+ const markup = renderToString(
+
+ About
+
+ );
+ expect(markup).toContain('href="/en/about"');
+ });
+ });
+
+ describe('getPathname', () => {
+ it('does not add a prefix for the default locale', () => {
+ expect(getPathname({locale: 'en', href: '/unknown'})).toBe('/unknown');
+ });
+
+ it('does not add a prefix for a secondary locale', () => {
+ expect(getPathname({locale: 'de', href: '/about'})).toBe('/about');
+ });
+
+ it('requires a locale', () => {
+ // @ts-expect-error -- Missing locale
+ // eslint-disable-next-line no-unused-expressions
+ () => getPathname({href: '/about'});
+ });
+ });
+
+ describe.each([
+ ['redirect', redirect, nextRedirect],
+ ['permanentRedirect', permanentRedirect, nextPermanentRedirect]
+ ])('%s', (_, redirectFn, nextRedirectFn) => {
+ it('can redirect for the default locale', () => {
+ runInRender(() => redirectFn({href: '/', locale: 'en'}));
+ expect(nextRedirectFn).toHaveBeenLastCalledWith('/');
+ });
+
+ it('forwards a redirect type', () => {
+ runInRender(() =>
+ redirectFn({href: '/', locale: 'en'}, RedirectType.push)
+ );
+ expect(nextRedirectFn).toHaveBeenLastCalledWith('/', RedirectType.push);
+ });
+ });
+ });
+
+ describe("localePrefix: 'never', with `domains`", () => {
+ const {Link, getPathname, permanentRedirect, redirect} = createNavigation({
+ locales,
+ defaultLocale,
+ domains,
+ localePrefix: 'never'
+ });
+
+ describe('Link', () => {
+ it('renders no prefix during SSR', () => {
+ const markup = renderToString(About);
+ expect(markup).toContain('href="/about"');
+ });
+
+ it('renders a prefix when linking to a secondary locale', () => {
+ const markup = renderToString(
+
+ Über uns
+
+ );
+ expect(markup).toContain('href="/de/about"');
+ expect(markup).toContain('hrefLang="de"');
+ });
+
+ it('can link to a pathname on another domain', () => {
+ const markup = renderToString(
+
+ Über uns
+
+ );
+ expect(markup).toContain('href="//example.de/about"');
+ expect(markup).toContain('hrefLang="de"');
+ });
+ });
+
+ describe('getPathname', () => {
+ it('does not add a prefix for the default locale', () => {
+ const originalConsoleError = console.error;
+ console.error = vi.fn();
+ expect(getPathname({locale: 'en', href: '/about'})).toBe('/about');
+ expect(console.error).not.toHaveBeenCalled();
+ console.error = originalConsoleError;
+ });
+ });
+
+ describe.each([
+ ['redirect', redirect, nextRedirect],
+ ['permanentRedirect', permanentRedirect, nextPermanentRedirect]
+ ])('%s', (_, redirectFn, nextRedirectFn) => {
+ it('adds no prefix for the default locale', () => {
+ runInRender(() => redirectFn({href: '/', locale: 'en'}));
+ expect(nextRedirectFn).toHaveBeenLastCalledWith('/');
+ });
+ });
+ });
+});
diff --git a/packages/next-intl/src/navigation/createSharedPathnamesNavigation.test.tsx b/packages/next-intl/src/navigation/createSharedPathnamesNavigation.test.tsx
index e950e05bd..b54612181 100644
--- a/packages/next-intl/src/navigation/createSharedPathnamesNavigation.test.tsx
+++ b/packages/next-intl/src/navigation/createSharedPathnamesNavigation.test.tsx
@@ -14,7 +14,7 @@ import {getRequestLocale} from '../server/react-server/RequestLocale';
import {getLocalePrefix} from '../shared/utils';
import createSharedPathnamesNavigationClient from './react-client/createSharedPathnamesNavigation';
import createSharedPathnamesNavigationServer from './react-server/createSharedPathnamesNavigation';
-import BaseLink from './shared/BaseLink';
+import LegacyBaseLink from './shared/LegacyBaseLink';
vi.mock('next/navigation', async () => {
const actual = await vi.importActual('next/navigation');
@@ -39,7 +39,7 @@ vi.mock('../../src/navigation/react-server/ServerLink', () => ({
const finalLocale = locale || 'en';
const prefix = getLocalePrefix(finalLocale, localePrefix);
return (
- , 'localePrefix'> & {
- localePrefix?: LocalePrefixConfigVerbose;
+ localePrefix?: LocalePrefixConfigVerbose;
},
ref
) => (
diff --git a/packages/next-intl/src/navigation/react-client/ClientLink.tsx b/packages/next-intl/src/navigation/react-client/ClientLink.tsx
index 199b207df..6d047747d 100644
--- a/packages/next-intl/src/navigation/react-client/ClientLink.tsx
+++ b/packages/next-intl/src/navigation/react-client/ClientLink.tsx
@@ -1,27 +1,37 @@
import React, {ComponentProps, ReactElement, forwardRef} from 'react';
import useLocale from '../../react-client/useLocale';
-import {Locales, LocalePrefixConfigVerbose} from '../../routing/types';
+import {
+ Locales,
+ LocalePrefixConfigVerbose,
+ LocalePrefixMode
+} from '../../routing/types';
import {getLocalePrefix} from '../../shared/utils';
-import BaseLink from '../shared/BaseLink';
+import LegacyBaseLink from '../shared/LegacyBaseLink';
-type Props = Omit<
- ComponentProps,
+type Props<
+ AppLocales extends Locales,
+ AppLocalePrefixMode extends LocalePrefixMode
+> = Omit<
+ ComponentProps,
'locale' | 'prefix' | 'localePrefixMode'
> & {
locale?: AppLocales[number];
- localePrefix: LocalePrefixConfigVerbose;
+ localePrefix: LocalePrefixConfigVerbose;
};
-function ClientLink(
- {locale, localePrefix, ...rest}: Props,
- ref: Props['ref']
+function ClientLink<
+ AppLocales extends Locales,
+ AppLocalePrefixMode extends LocalePrefixMode
+>(
+ {locale, localePrefix, ...rest}: Props,
+ ref: Props['ref']
) {
const defaultLocale = useLocale();
const finalLocale = locale || defaultLocale;
const prefix = getLocalePrefix(finalLocale, localePrefix);
return (
- (
* page to be overwritten before the user even decides to change the locale.
*/
const ClientLinkWithRef = forwardRef(ClientLink) as <
- AppLocales extends Locales
+ AppLocales extends Locales,
+ AppLocalePrefixMode extends LocalePrefixMode
>(
- props: Props & {ref?: Props['ref']}
+ props: Props & {
+ ref?: Props['ref'];
+ }
) => ReactElement;
(ClientLinkWithRef as any).displayName = 'ClientLink';
export default ClientLinkWithRef;
diff --git a/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.test.tsx b/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.test.tsx
index d8e3f8bf8..e711d0f63 100644
--- a/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.test.tsx
+++ b/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.test.tsx
@@ -433,7 +433,6 @@ describe("localePrefix: 'as-needed'", () => {
useRouter: useRouterWithUnknown
} = createLocalizedPathnamesNavigation({
locales,
- // eslint-disable-next-line @typescript-eslint/ban-types
pathnames: pathnames as typeof pathnames & Record
});
Unknown;
diff --git a/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.tsx b/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.tsx
index fb7ec7284..61a8c5671 100644
--- a/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.tsx
+++ b/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.tsx
@@ -4,7 +4,12 @@ import {
receiveRoutingConfig,
RoutingConfigLocalizedNavigation
} from '../../routing/config';
-import {Locales, Pathnames} from '../../routing/types';
+import {
+ DomainsConfig,
+ LocalePrefixMode,
+ Locales,
+ Pathnames
+} from '../../routing/types';
import {ParametersExceptFirst} from '../../shared/types';
import {
compileLocalizedPathname,
@@ -20,8 +25,17 @@ import useBaseRouter from './useBaseRouter';
export default function createLocalizedPathnamesNavigation<
AppLocales extends Locales,
- AppPathnames extends Pathnames
->(routing: RoutingConfigLocalizedNavigation) {
+ AppLocalePrefixMode extends LocalePrefixMode = 'always',
+ AppPathnames extends Pathnames = never,
+ AppDomains extends DomainsConfig = never
+>(
+ routing: RoutingConfigLocalizedNavigation<
+ AppLocales,
+ AppLocalePrefixMode,
+ AppPathnames,
+ AppDomains
+ >
+) {
const config = receiveRoutingConfig(routing);
function useTypedLocale(): AppLocales[number] {
diff --git a/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx b/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx
new file mode 100644
index 000000000..740776f2a
--- /dev/null
+++ b/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx
@@ -0,0 +1,714 @@
+import {fireEvent, render, screen} from '@testing-library/react';
+import {PrefetchKind} from 'next/dist/client/components/router-reducer/router-reducer-types';
+import {
+ useParams,
+ usePathname as useNextPathname,
+ useRouter as useNextRouter
+} from 'next/navigation';
+import React from 'react';
+import {beforeEach, describe, expect, it, vi} from 'vitest';
+import {NextIntlClientProvider} from '../../react-client';
+import {DomainsConfig, Pathnames} from '../../routing';
+import createNavigation from './createNavigation';
+
+vi.mock('next/navigation');
+
+function mockCurrentLocale(locale: string) {
+ vi.mocked(useParams<{locale: string}>).mockImplementation(() => ({
+ locale
+ }));
+}
+
+function mockLocation(location: Partial) {
+ delete (global.window as any).location;
+ global.window ??= Object.create(window);
+ (global.window as any).location = location;
+
+ if (location.pathname) {
+ vi.mocked(useNextPathname).mockReturnValue(location.pathname);
+ }
+}
+
+beforeEach(() => {
+ mockCurrentLocale('en');
+ mockLocation({host: 'localhost:3000', pathname: '/en'});
+
+ const router = {
+ push: vi.fn(),
+ replace: vi.fn(),
+ prefetch: vi.fn(),
+ back: vi.fn(),
+ forward: vi.fn(),
+ refresh: vi.fn()
+ };
+ vi.mocked(useNextRouter).mockImplementation(() => router);
+});
+
+const locales = ['en', 'de', 'ja'] as const;
+const defaultLocale = 'en' as const;
+
+const domains: DomainsConfig = [
+ {
+ defaultLocale: 'en',
+ domain: 'example.com'
+ },
+ {
+ defaultLocale: 'de',
+ domain: 'example.de',
+ locales: ['de', 'en']
+ }
+];
+
+const pathnames = {
+ '/': '/',
+ '/about': {
+ en: '/about',
+ de: '/ueber-uns',
+ ja: '/約'
+ },
+ '/news/[articleSlug]-[articleId]': {
+ en: '/news/[articleSlug]-[articleId]',
+ de: '/neuigkeiten/[articleSlug]-[articleId]',
+ ja: '/ニュース/[articleSlug]-[articleId]'
+ },
+ '/categories/[...parts]': {
+ en: '/categories/[...parts]',
+ de: '/kategorien/[...parts]',
+ ja: '/カテゴリ/[...parts]'
+ },
+ '/catch-all/[[...parts]]': '/catch-all/[[...parts]]'
+} satisfies Pathnames;
+
+function getRenderPathname(usePathname: () => Return) {
+ return () => {
+ function Component() {
+ return usePathname();
+ }
+ render();
+ };
+}
+
+function getInvokeRouter(useRouter: () => Router) {
+ return function invokeRouter(cb: (router: Router) => void) {
+ function Component() {
+ const router = useRouter();
+ cb(router);
+ return null;
+ }
+ render();
+ };
+}
+
+describe("localePrefix: 'always'", () => {
+ const {Link, usePathname, useRouter} = createNavigation({
+ locales,
+ defaultLocale,
+ localePrefix: 'always'
+ });
+
+ describe('Link', () => {
+ describe('usage outside of Next.js', () => {
+ beforeEach(() => {
+ vi.mocked(useParams).mockImplementation((() => null) as any);
+ });
+
+ it('works with a provider', () => {
+ render(
+
+ Test
+
+ );
+ expect(
+ screen.getByRole('link', {name: 'Test'}).getAttribute('href')
+ ).toBe('/en/test');
+ });
+
+ it('throws without a provider', () => {
+ expect(() => render(Test)).toThrow(
+ 'No intl context found. Have you configured the provider?'
+ );
+ });
+ });
+
+ it('can receive a ref', () => {
+ let ref;
+
+ render(
+ {
+ ref = node;
+ }}
+ href="/test"
+ >
+ Test
+
+ );
+
+ expect(ref).toBeDefined();
+ });
+ });
+
+ describe('useRouter', () => {
+ const invokeRouter = getInvokeRouter(useRouter);
+
+ it('leaves unrelated router functionality in place', () => {
+ (['back', 'forward', 'refresh'] as const).forEach((method) => {
+ invokeRouter((router) => router[method]());
+ expect(useNextRouter()[method]).toHaveBeenCalled();
+ });
+ });
+
+ describe.each(['push', 'replace'] as const)('`%s`', (method) => {
+ it('prefixes with the default locale', () => {
+ invokeRouter((router) => router[method]('/about'));
+ expect(useNextRouter()[method]).toHaveBeenCalledWith('/en/about');
+ });
+
+ it('prefixes with a secondary locale', () => {
+ // Being able to accept a string and not only a strictly typed locale is
+ // important in order to be able to use a result from `useLocale()`.
+ // This is less relevant for `Link`, but this should be in sync across
+ // al navigation APIs (see https://github.com/amannn/next-intl/issues/1377)
+ const locale = 'de' as string;
+
+ invokeRouter((router) => router[method]('/about', {locale}));
+ expect(useNextRouter()[method]).toHaveBeenCalledWith('/de/about');
+ });
+
+ it('passes through unknown options to the Next.js router', () => {
+ invokeRouter((router) => router[method]('/about', {scroll: true}));
+ expect(useNextRouter()[method]).toHaveBeenCalledWith('/en/about', {
+ scroll: true
+ });
+ });
+
+ it('handles search params via a final string', () => {
+ invokeRouter((router) => router[method]('/test?foo=bar'));
+ expect(useNextRouter()[method]).toHaveBeenCalledWith(
+ '/en/test?foo=bar'
+ );
+ });
+
+ it('handles search params via an object', () => {
+ invokeRouter((router) =>
+ router[method]({
+ pathname: '/test',
+ query: {foo: 'bar'}
+ })
+ );
+ expect(useNextRouter()[method]).toHaveBeenCalledWith(
+ '/en/test?foo=bar'
+ );
+ });
+
+ it('passes through absolute urls', () => {
+ invokeRouter((router) => router[method]('https://example.com'));
+ expect(useNextRouter()[method]).toHaveBeenCalledWith(
+ 'https://example.com'
+ );
+ });
+
+ it('passes through relative urls', () => {
+ invokeRouter((router) => router[method]('about'));
+ expect(useNextRouter()[method]).toHaveBeenCalledWith('about');
+ });
+ });
+
+ describe('prefetch', () => {
+ it('prefixes with the default locale', () => {
+ invokeRouter((router) => router.prefetch('/about'));
+ expect(useNextRouter().prefetch).toHaveBeenCalledWith('/en/about');
+ });
+
+ it('prefixes with a secondary locale', () => {
+ invokeRouter((router) =>
+ router.prefetch('/about', {locale: 'de', kind: PrefetchKind.FULL})
+ );
+ expect(useNextRouter().prefetch).toHaveBeenCalledWith('/de/about', {
+ kind: 'full'
+ });
+ });
+ });
+ });
+
+ describe('usePathname', () => {
+ const renderPathname = getRenderPathname(usePathname);
+
+ it('returns the correct pathname for the default locale', () => {
+ mockCurrentLocale('en');
+ mockLocation({pathname: '/en/about'});
+
+ renderPathname();
+ screen.getByText('/about');
+ });
+
+ it('returns the correct pathname for a secondary locale', () => {
+ mockCurrentLocale('de');
+ mockLocation({pathname: '/de/about'});
+
+ renderPathname();
+ screen.getByText('/about');
+ });
+ });
+});
+
+describe("localePrefix: 'always', with `basePath`", () => {
+ const {useRouter} = createNavigation({
+ locales,
+ defaultLocale,
+ localePrefix: 'always'
+ });
+
+ beforeEach(() => {
+ mockLocation({pathname: '/base/path/en'});
+ });
+
+ describe('useRouter', () => {
+ const invokeRouter = getInvokeRouter(useRouter);
+
+ it('can push', () => {
+ invokeRouter((router) => router.push('/test'));
+ expect(useNextRouter().push).toHaveBeenCalledWith('/en/test');
+ });
+
+ it('can replace', () => {
+ invokeRouter((router) => router.replace('/test'));
+ expect(useNextRouter().replace).toHaveBeenCalledWith('/en/test');
+ });
+
+ it('can prefetch', () => {
+ invokeRouter((router) => router.prefetch('/test'));
+ expect(useNextRouter().prefetch).toHaveBeenCalledWith('/en/test');
+ });
+ });
+});
+
+describe("localePrefix: 'always', with `pathnames`", () => {
+ const {usePathname, useRouter} = createNavigation({
+ locales,
+ defaultLocale,
+ localePrefix: 'always',
+ pathnames
+ });
+
+ describe('useRouter', () => {
+ const invokeRouter = getInvokeRouter(useRouter);
+
+ describe.each(['push', 'replace'] as const)('`%s`', (method) => {
+ it('localizes a pathname for the default locale', () => {
+ invokeRouter((router) => router[method]('/about'));
+ expect(useNextRouter()[method]).toHaveBeenCalledWith('/en/about');
+ });
+
+ it('localizes a pathname for a secondary locale', () => {
+ invokeRouter((router) => router[method]('/about', {locale: 'de'}));
+ expect(useNextRouter()[method]).toHaveBeenCalledWith('/de/ueber-uns');
+ });
+
+ it('handles pathname params', () => {
+ invokeRouter((router) =>
+ router[method]({
+ pathname: '/news/[articleSlug]-[articleId]',
+ params: {
+ articleSlug: 'launch-party',
+ articleId: '3'
+ }
+ })
+ );
+ expect(useNextRouter()[method]).toHaveBeenCalledWith(
+ '/en/news/launch-party-3'
+ );
+ });
+
+ it('handles search params', () => {
+ invokeRouter((router) =>
+ router[method]({
+ pathname: '/about',
+ query: {
+ foo: 'bar'
+ }
+ })
+ );
+ expect(useNextRouter()[method]).toHaveBeenCalledWith(
+ '/en/about?foo=bar'
+ );
+ });
+
+ it('disallows unknown pathnames', () => {
+ // @ts-expect-error -- Unknown pathname
+ invokeRouter((router) => router[method]('/unknown'));
+
+ // Still works
+ expect(useNextRouter()[method]).toHaveBeenCalledWith('/en/unknown');
+ });
+ });
+ });
+
+ describe('usePathname', () => {
+ it('returns a typed pathname', () => {
+ type Return = ReturnType;
+
+ '/about' satisfies Return;
+ '/categories/[...parts]' satisfies Return;
+
+ // @ts-expect-error
+ '/unknown' satisfies Return;
+ });
+ });
+});
+
+describe("localePrefix: 'always', custom `prefixes`", () => {
+ const {usePathname} = createNavigation({
+ locales,
+ localePrefix: {
+ mode: 'always',
+ prefixes: {
+ en: '/uk'
+ }
+ }
+ });
+ const renderPathname = getRenderPathname(usePathname);
+
+ describe('usePathname', () => {
+ it('returns the correct pathname for a custom locale prefix', () => {
+ mockCurrentLocale('en');
+ mockLocation({pathname: '/uk/about'});
+ renderPathname();
+ screen.getByText('/about');
+ });
+ });
+});
+
+describe("localePrefix: 'as-needed'", () => {
+ const {usePathname, useRouter} = createNavigation({
+ locales,
+ defaultLocale,
+ localePrefix: 'as-needed'
+ });
+
+ function renderPathname() {
+ function Component() {
+ return usePathname();
+ }
+ render();
+ }
+
+ describe('useRouter', () => {
+ const invokeRouter = getInvokeRouter(useRouter);
+
+ it('leaves unrelated router functionality in place', () => {
+ (['back', 'forward', 'refresh'] as const).forEach((method) => {
+ invokeRouter((router) => router[method]());
+ expect(useNextRouter()[method]).toHaveBeenCalled();
+ });
+ });
+
+ describe.each(['push', 'replace'] as const)('`%s`', (method) => {
+ it('does not prefix the default locale', () => {
+ invokeRouter((router) => router[method]('/about'));
+ expect(useNextRouter()[method]).toHaveBeenCalledWith('/about');
+ });
+
+ it('prefixes a secondary locale', () => {
+ invokeRouter((router) => router[method]('/about', {locale: 'de'}));
+ expect(useNextRouter()[method]).toHaveBeenCalledWith('/de/about');
+ });
+ });
+
+ describe('prefetch', () => {
+ it('prefixes with the default locale', () => {
+ invokeRouter((router) => router.prefetch('/about'));
+ expect(useNextRouter().prefetch).toHaveBeenCalledWith('/about');
+ });
+
+ it('prefixes with a secondary locale', () => {
+ invokeRouter((router) => router.prefetch('/about', {locale: 'de'}));
+ expect(useNextRouter().prefetch).toHaveBeenCalledWith('/de/about');
+ });
+ });
+ });
+
+ describe('usePathname', () => {
+ it('returns the correct pathname for the default locale', () => {
+ mockCurrentLocale('en');
+ mockLocation({pathname: '/about'});
+
+ renderPathname();
+ screen.getByText('/about');
+ });
+
+ it('returns the correct pathname for a secondary locale', () => {
+ mockCurrentLocale('de');
+ mockLocation({pathname: '/de/about'});
+
+ renderPathname();
+ screen.getByText('/about');
+ });
+ });
+});
+
+describe("localePrefix: 'as-needed', with `basePath` and `domains`", () => {
+ const {useRouter} = createNavigation({
+ locales,
+ defaultLocale,
+ domains,
+ localePrefix: 'as-needed'
+ });
+
+ describe('useRouter', () => {
+ const invokeRouter = getInvokeRouter(useRouter);
+
+ describe('example.com, defaultLocale: "en"', () => {
+ beforeEach(() => {
+ mockLocation({pathname: '/base/path/about', host: 'example.com'});
+ });
+
+ it('can compute the correct pathname when the default locale on the current domain matches the current locale', () => {
+ invokeRouter((router) => router.push('/test'));
+ expect(useNextRouter().push).toHaveBeenCalledWith('/test');
+ });
+
+ it('can compute the correct pathname when the default locale on the current domain does not match the current locale', () => {
+ invokeRouter((router) => router.push('/test', {locale: 'de'}));
+ expect(useNextRouter().push).toHaveBeenCalledWith('/de/test');
+ });
+ });
+
+ describe('example.de, defaultLocale: "de"', () => {
+ beforeEach(() => {
+ mockCurrentLocale('de');
+ mockLocation({pathname: '/base/path/about', host: 'example.de'});
+ });
+
+ it('can compute the correct pathname when the default locale on the current domain matches the current locale', () => {
+ invokeRouter((router) => router.push('/test'));
+ expect(useNextRouter().push).toHaveBeenCalledWith('/test');
+ });
+
+ it('can compute the correct pathname when the default locale on the current domain does not match the current locale', () => {
+ invokeRouter((router) => router.push('/test', {locale: 'en'}));
+ expect(useNextRouter().push).toHaveBeenCalledWith('/en/test');
+ });
+ });
+ });
+});
+
+describe("localePrefix: 'as-needed', with `domains`", () => {
+ const {usePathname, useRouter} = createNavigation({
+ locales,
+ defaultLocale,
+ domains,
+ localePrefix: 'as-needed'
+ });
+
+ describe('useRouter', () => {
+ const invokeRouter = getInvokeRouter(useRouter);
+
+ describe.each(['push', 'replace'] as const)('`%s`', (method) => {
+ it('does not prefix the default locale when on a domain with a matching defaultLocale', () => {
+ mockCurrentLocale('en');
+ mockLocation({pathname: '/about', host: 'example.com'});
+ invokeRouter((router) => router[method]('/about'));
+ expect(useNextRouter()[method]).toHaveBeenCalledWith('/about');
+ });
+
+ it('does not prefix the default locale when on a domain with a different defaultLocale', () => {
+ mockCurrentLocale('de');
+ mockLocation({pathname: '/about', host: 'example.de'});
+ invokeRouter((router) => router[method]('/about'));
+ expect(useNextRouter()[method]).toHaveBeenCalledWith('/about');
+ });
+
+ it('does not prefix the default locale when on an unknown domain', () => {
+ const consoleSpy = vi.spyOn(console, 'error');
+ mockCurrentLocale('en');
+ mockLocation({pathname: '/about', host: 'localhost:3000'});
+ invokeRouter((router) => router[method]('/about'));
+ expect(useNextRouter()[method]).toHaveBeenCalledWith('/about');
+ expect(consoleSpy).not.toHaveBeenCalled();
+ consoleSpy.mockRestore();
+ });
+
+ it('prefixes the default locale when on a domain with a different defaultLocale', () => {
+ mockCurrentLocale('de');
+ mockLocation({pathname: '/about', host: 'example.de'});
+ invokeRouter((router) => router[method]('/about', {locale: 'en'}));
+ expect(useNextRouter()[method]).toHaveBeenCalledWith('/en/about');
+ });
+ });
+ });
+
+ const renderPathname = getRenderPathname(usePathname);
+
+ describe('usePathname', () => {
+ it('returns the correct pathname for the default locale', () => {
+ mockCurrentLocale('en');
+ mockLocation({pathname: '/about', host: 'example.com'});
+ renderPathname();
+ screen.getByText('/about');
+ });
+
+ it('returns the correct pathname for a secondary locale', () => {
+ mockCurrentLocale('de');
+ mockLocation({pathname: '/de/about', host: 'example.com'});
+ renderPathname();
+ screen.getByText('/about');
+ });
+
+ it('returns the correct pathname for the default locale on a domain with a different defaultLocale', () => {
+ mockCurrentLocale('de');
+ mockLocation({pathname: '/about', host: 'example.de'});
+ renderPathname();
+ screen.getByText('/about');
+ });
+
+ it('returns the correct pathname for a secondary locale on a domain with a different defaultLocale', () => {
+ mockCurrentLocale('en');
+ mockLocation({pathname: '/en/about', host: 'example.de'});
+ renderPathname();
+ screen.getByText('/about');
+ });
+ });
+});
+
+describe("localePrefix: 'never'", () => {
+ const {Link, usePathname, useRouter} = createNavigation({
+ locales,
+ defaultLocale,
+ localePrefix: 'never'
+ });
+
+ function renderPathname() {
+ function Component() {
+ return usePathname();
+ }
+ render();
+ }
+
+ describe('Link', () => {
+ it('keeps the cookie value in sync', () => {
+ global.document.cookie = 'NEXT_LOCALE=en';
+ render(
+
+ Test
+
+ );
+ expect(document.cookie).toContain('NEXT_LOCALE=en');
+ fireEvent.click(screen.getByRole('link', {name: 'Test'}));
+ expect(document.cookie).toContain('NEXT_LOCALE=de');
+ });
+
+ it('updates the href when the query changes', () => {
+ const {rerender} = render(Test);
+ expect(
+ screen.getByRole('link', {name: 'Test'}).getAttribute('href')
+ ).toBe('/');
+ rerender(Test);
+ expect(
+ screen.getByRole('link', {name: 'Test'}).getAttribute('href')
+ ).toBe('/?foo=bar');
+ });
+ });
+
+ describe('useRouter', () => {
+ const invokeRouter = getInvokeRouter(useRouter);
+
+ it('leaves unrelated router functionality in place', () => {
+ (['back', 'forward', 'refresh'] as const).forEach((method) => {
+ invokeRouter((router) => router[method]());
+ expect(useNextRouter()[method]).toHaveBeenCalled();
+ });
+ });
+
+ describe.each(['push', 'replace'] as const)('`%s`', (method) => {
+ it('does not prefix the default locale', () => {
+ invokeRouter((router) => router[method]('/about'));
+ expect(useNextRouter()[method]).toHaveBeenCalledWith('/about');
+ });
+
+ it('does not prefix a secondary locale', () => {
+ invokeRouter((router) => router[method]('/about', {locale: 'de'}));
+ expect(useNextRouter()[method]).toHaveBeenCalledWith('/about');
+ });
+ });
+
+ it('keeps the cookie value in sync', () => {
+ document.cookie = 'NEXT_LOCALE=en';
+
+ invokeRouter((router) => router.push('/about', {locale: 'de'}));
+ expect(document.cookie).toContain('NEXT_LOCALE=de');
+
+ invokeRouter((router) => router.push('/test'));
+ expect(document.cookie).toContain('NEXT_LOCALE=de');
+
+ invokeRouter((router) => router.replace('/about', {locale: 'de'}));
+ expect(document.cookie).toContain('NEXT_LOCALE=de');
+
+ invokeRouter((router) =>
+ router.prefetch('/about', {locale: 'ja', kind: PrefetchKind.AUTO})
+ );
+ expect(document.cookie).toContain('NEXT_LOCALE=ja');
+ });
+
+ describe('prefetch', () => {
+ it('does not prefix the default locale', () => {
+ invokeRouter((router) => router.prefetch('/about'));
+ expect(useNextRouter().prefetch).toHaveBeenCalledWith('/about');
+ });
+
+ it('does not prefix a secondary locale', () => {
+ invokeRouter((router) => router.prefetch('/about', {locale: 'de'}));
+ expect(useNextRouter().prefetch).toHaveBeenCalledWith('/about');
+ });
+ });
+ });
+
+ describe('usePathname', () => {
+ it('returns the correct pathname for the default locale', () => {
+ mockCurrentLocale('en');
+ mockLocation({pathname: '/about'});
+
+ renderPathname();
+ screen.getByText('/about');
+ });
+
+ it('returns the correct pathname for a secondary locale', () => {
+ mockCurrentLocale('de');
+ mockLocation({pathname: '/about'});
+
+ renderPathname();
+ screen.getByText('/about');
+ });
+ });
+});
+
+describe("localePrefix: 'never', with `basePath`", () => {
+ const {useRouter} = createNavigation({
+ locales,
+ defaultLocale,
+ localePrefix: 'never'
+ });
+
+ beforeEach(() => {
+ mockLocation({pathname: '/base/path/en'});
+ });
+
+ describe('useRouter', () => {
+ const invokeRouter = getInvokeRouter(useRouter);
+
+ it('can push', () => {
+ invokeRouter((router) => router.push('/test'));
+ expect(useNextRouter().push).toHaveBeenCalledWith('/test');
+ });
+
+ it('can replace', () => {
+ invokeRouter((router) => router.replace('/test'));
+ expect(useNextRouter().replace).toHaveBeenCalledWith('/test');
+ });
+
+ it('can prefetch', () => {
+ invokeRouter((router) => router.prefetch('/test'));
+ expect(useNextRouter().prefetch).toHaveBeenCalledWith('/test');
+ });
+ });
+});
diff --git a/packages/next-intl/src/navigation/react-client/createNavigation.tsx b/packages/next-intl/src/navigation/react-client/createNavigation.tsx
new file mode 100644
index 000000000..ec87381c3
--- /dev/null
+++ b/packages/next-intl/src/navigation/react-client/createNavigation.tsx
@@ -0,0 +1,141 @@
+import {
+ useRouter as useNextRouter,
+ usePathname as useNextPathname
+} from 'next/navigation';
+import {useMemo} from 'react';
+import useLocale from '../../react-client/useLocale';
+import {
+ RoutingConfigLocalizedNavigation,
+ RoutingConfigSharedNavigation
+} from '../../routing/config';
+import {
+ DomainsConfig,
+ LocalePrefixMode,
+ Locales,
+ Pathnames
+} from '../../routing/types';
+import createSharedNavigationFns from '../shared/createSharedNavigationFns';
+import syncLocaleCookie from '../shared/syncLocaleCookie';
+import {getRoute} from '../shared/utils';
+import useBasePathname from './useBasePathname';
+
+export default function createNavigation<
+ const AppLocales extends Locales,
+ const AppLocalePrefixMode extends LocalePrefixMode = 'always',
+ const AppPathnames extends Pathnames = never,
+ const AppDomains extends DomainsConfig = never
+>(
+ routing?: [AppPathnames] extends [never]
+ ?
+ | RoutingConfigSharedNavigation<
+ AppLocales,
+ AppLocalePrefixMode,
+ AppDomains
+ >
+ | undefined
+ : RoutingConfigLocalizedNavigation<
+ AppLocales,
+ AppLocalePrefixMode,
+ AppPathnames,
+ AppDomains
+ >
+) {
+ type Locale = AppLocales extends never ? string : AppLocales[number];
+
+ function useTypedLocale() {
+ return useLocale() as Locale;
+ }
+
+ const {Link, config, getPathname, ...redirects} = createSharedNavigationFns(
+ useTypedLocale,
+ routing
+ );
+
+ /** @see https://next-intl-docs.vercel.app/docs/routing/navigation#usepathname */
+ function usePathname(): [AppPathnames] extends [never]
+ ? string
+ : keyof AppPathnames {
+ const pathname = useBasePathname(config.localePrefix);
+ const locale = useTypedLocale();
+
+ // @ts-expect-error -- Mirror the behavior from Next.js, where `null` is returned when `usePathname` is used outside of Next, but the types indicate that a string is always returned.
+ return useMemo(
+ () =>
+ pathname &&
+ // @ts-expect-error -- This is fine
+ config.pathnames
+ ? getRoute(
+ locale,
+ pathname,
+ // @ts-expect-error -- This is fine
+ config.pathnames
+ )
+ : pathname,
+ [locale, pathname]
+ );
+ }
+
+ function useRouter() {
+ const router = useNextRouter();
+ const curLocale = useTypedLocale();
+ const nextPathname = useNextPathname();
+
+ return useMemo(() => {
+ function createHandler<
+ Options,
+ Fn extends (href: string, options?: Options) => void
+ >(fn: Fn) {
+ return function handler(
+ href: Parameters[0]['href'],
+ options?: Partial & {locale?: string}
+ ): void {
+ const {locale: nextLocale, ...rest} = options || {};
+
+ // @ts-expect-error -- We're passing a domain here just in case
+ const pathname = getPathname({
+ href,
+ locale: nextLocale || curLocale,
+ domain: window.location.host
+ });
+
+ const args: [href: string, options?: Options] = [pathname];
+ if (Object.keys(rest).length > 0) {
+ // @ts-expect-error -- This is fine
+ args.push(rest);
+ }
+
+ fn(...args);
+
+ syncLocaleCookie(nextPathname, curLocale, nextLocale);
+ };
+ }
+
+ return {
+ ...router,
+ /** @see https://next-intl-docs.vercel.app/docs/routing/navigation#userouter */
+ push: createHandler<
+ Parameters[1],
+ typeof router.push
+ >(router.push),
+ /** @see https://next-intl-docs.vercel.app/docs/routing/navigation#userouter */
+ replace: createHandler<
+ Parameters[1],
+ typeof router.replace
+ >(router.replace),
+ /** @see https://next-intl-docs.vercel.app/docs/routing/navigation#userouter */
+ prefetch: createHandler<
+ Parameters[1],
+ typeof router.prefetch
+ >(router.prefetch)
+ };
+ }, [curLocale, nextPathname, router]);
+ }
+
+ return {
+ ...redirects,
+ Link,
+ usePathname,
+ useRouter,
+ getPathname
+ };
+}
diff --git a/packages/next-intl/src/navigation/react-client/createSharedPathnamesNavigation.tsx b/packages/next-intl/src/navigation/react-client/createSharedPathnamesNavigation.tsx
index 4050010f9..cdc199425 100644
--- a/packages/next-intl/src/navigation/react-client/createSharedPathnamesNavigation.tsx
+++ b/packages/next-intl/src/navigation/react-client/createSharedPathnamesNavigation.tsx
@@ -3,7 +3,7 @@ import {
receiveLocalePrefixConfig,
RoutingConfigSharedNavigation
} from '../../routing/config';
-import {Locales} from '../../routing/types';
+import {DomainsConfig, LocalePrefixMode, Locales} from '../../routing/types';
import {ParametersExceptFirst} from '../../shared/types';
import ClientLink from './ClientLink';
import {clientRedirect, clientPermanentRedirect} from './redirects';
@@ -11,17 +11,25 @@ import useBasePathname from './useBasePathname';
import useBaseRouter from './useBaseRouter';
export default function createSharedPathnamesNavigation<
- const AppLocales extends Locales
->(routing?: RoutingConfigSharedNavigation) {
+ AppLocales extends Locales,
+ AppLocalePrefixMode extends LocalePrefixMode,
+ AppDomains extends DomainsConfig = never
+>(
+ routing?: RoutingConfigSharedNavigation<
+ AppLocales,
+ AppLocalePrefixMode,
+ AppDomains
+ >
+) {
const localePrefix = receiveLocalePrefixConfig(routing?.localePrefix);
type LinkProps = Omit<
- ComponentProps>,
+ ComponentProps>,
'localePrefix'
>;
function Link(props: LinkProps, ref: LinkProps['ref']) {
return (
-
+
ref={ref}
localePrefix={localePrefix}
{...props}
@@ -54,7 +62,7 @@ export default function createSharedPathnamesNavigation<
}
function useRouter() {
- return useBaseRouter(localePrefix);
+ return useBaseRouter(localePrefix);
}
return {
diff --git a/packages/next-intl/src/navigation/react-client/index.tsx b/packages/next-intl/src/navigation/react-client/index.tsx
index f814e8378..2cc3b23d3 100644
--- a/packages/next-intl/src/navigation/react-client/index.tsx
+++ b/packages/next-intl/src/navigation/react-client/index.tsx
@@ -1,5 +1,6 @@
export {default as createSharedPathnamesNavigation} from './createSharedPathnamesNavigation';
export {default as createLocalizedPathnamesNavigation} from './createLocalizedPathnamesNavigation';
+export {default as createNavigation} from './createNavigation';
import type {
Pathnames as PathnamesDeprecatedExport,
diff --git a/packages/next-intl/src/navigation/react-client/useBasePathname.tsx b/packages/next-intl/src/navigation/react-client/useBasePathname.tsx
index 6d288e6ea..105ef56e1 100644
--- a/packages/next-intl/src/navigation/react-client/useBasePathname.tsx
+++ b/packages/next-intl/src/navigation/react-client/useBasePathname.tsx
@@ -1,31 +1,21 @@
-'use client';
-
import {usePathname as useNextPathname} from 'next/navigation';
import {useMemo} from 'react';
import useLocale from '../../react-client/useLocale';
-import {Locales, LocalePrefixConfigVerbose} from '../../routing/types';
+import {
+ Locales,
+ LocalePrefixConfigVerbose,
+ LocalePrefixMode
+} from '../../routing/types';
import {
getLocalePrefix,
hasPathnamePrefixed,
unprefixPathname
} from '../../shared/utils';
-/**
- * Returns the pathname without a potential locale prefix.
- *
- * @example
- * ```tsx
- * 'use client';
- *
- * import {usePathname} from 'next-intl/client';
- *
- * // When the user is on `/en`, this will be `/`
- * const pathname = usePathname();
- * ```
- */
-export default function useBasePathname(
- localePrefix: LocalePrefixConfigVerbose
-) {
+export default function useBasePathname<
+ AppLocales extends Locales,
+ AppLocalePrefixMode extends LocalePrefixMode
+>(localePrefix: LocalePrefixConfigVerbose) {
// The types aren't entirely correct here. Outside of Next.js
// `useParams` can be called, but the return type is `null`.
diff --git a/packages/next-intl/src/navigation/react-client/useBaseRouter.tsx b/packages/next-intl/src/navigation/react-client/useBaseRouter.tsx
index 9be7e2f49..8f3a6fad0 100644
--- a/packages/next-intl/src/navigation/react-client/useBaseRouter.tsx
+++ b/packages/next-intl/src/navigation/react-client/useBaseRouter.tsx
@@ -1,7 +1,11 @@
import {useRouter as useNextRouter, usePathname} from 'next/navigation';
import {useMemo} from 'react';
import useLocale from '../../react-client/useLocale';
-import {Locales, LocalePrefixConfigVerbose} from '../../routing/types';
+import {
+ Locales,
+ LocalePrefixConfigVerbose,
+ LocalePrefixMode
+} from '../../routing/types';
import {getLocalePrefix, localizeHref} from '../../shared/utils';
import syncLocaleCookie from '../shared/syncLocaleCookie';
import {getBasePath} from '../shared/utils';
@@ -29,9 +33,10 @@ type IntlNavigateOptions = {
* router.push('/about', {locale: 'de'});
* ```
*/
-export default function useBaseRouter(
- localePrefix: LocalePrefixConfigVerbose
-) {
+export default function useBaseRouter<
+ AppLocales extends Locales,
+ AppLocalePrefixMode extends LocalePrefixMode
+>(localePrefix: LocalePrefixConfigVerbose) {
const router = useNextRouter();
const locale = useLocale();
const pathname = usePathname();
diff --git a/packages/next-intl/src/navigation/react-server/ServerLink.tsx b/packages/next-intl/src/navigation/react-server/ServerLink.tsx
index c254a6c39..39de1ee61 100644
--- a/packages/next-intl/src/navigation/react-server/ServerLink.tsx
+++ b/packages/next-intl/src/navigation/react-server/ServerLink.tsx
@@ -1,27 +1,35 @@
import React, {ComponentProps} from 'react';
-import {Locales, LocalePrefixConfigVerbose} from '../../routing/types';
+import {
+ Locales,
+ LocalePrefixConfigVerbose,
+ LocalePrefixMode
+} from '../../routing/types';
import {getLocale} from '../../server.react-server';
import {getLocalePrefix} from '../../shared/utils';
-import BaseLink from '../shared/BaseLink';
+import LegacyBaseLink from '../shared/LegacyBaseLink';
-type Props = Omit<
- ComponentProps,
+// Only used by legacy navigation APIs, can be removed when they are removed
+
+type Props<
+ AppLocales extends Locales,
+ AppLocalePrefixMode extends LocalePrefixMode
+> = Omit<
+ ComponentProps,
'locale' | 'prefix' | 'localePrefixMode'
> & {
locale?: AppLocales[number];
- localePrefix: LocalePrefixConfigVerbose;
+ localePrefix: LocalePrefixConfigVerbose;
};
-export default async function ServerLink({
- locale,
- localePrefix,
- ...rest
-}: Props) {
+export default async function ServerLink<
+ AppLocales extends Locales,
+ AppLocalePrefixMode extends LocalePrefixMode
+>({locale, localePrefix, ...rest}: Props) {
const finalLocale = locale || (await getLocale());
const prefix = getLocalePrefix(finalLocale, localePrefix);
return (
-
->(routing: RoutingConfigLocalizedNavigation) {
+ AppLocalePrefixMode extends LocalePrefixMode = 'always',
+ AppPathnames extends Pathnames = never,
+ AppDomains extends DomainsConfig = never
+>(
+ routing: RoutingConfigLocalizedNavigation<
+ AppLocales,
+ AppLocalePrefixMode,
+ AppPathnames,
+ AppDomains
+ >
+) {
const config = receiveRoutingConfig(routing);
type LinkProps = Omit<
diff --git a/packages/next-intl/src/navigation/react-server/createNavigation.test.tsx b/packages/next-intl/src/navigation/react-server/createNavigation.test.tsx
new file mode 100644
index 000000000..dc491b08b
--- /dev/null
+++ b/packages/next-intl/src/navigation/react-server/createNavigation.test.tsx
@@ -0,0 +1,26 @@
+import {describe, expect, it, vi} from 'vitest';
+import createNavigation from './createNavigation';
+
+vi.mock('react');
+
+const {usePathname, useRouter} = createNavigation();
+
+describe('usePathname', () => {
+ it('should throw an error', () => {
+ expect(() => {
+ usePathname();
+ }).toThrowError(
+ '`usePathname` is not supported in Server Components. You can use this hook if you convert the calling component to a Client Component.'
+ );
+ });
+});
+
+describe('useRouter', () => {
+ it('should throw an error', () => {
+ expect(() => {
+ useRouter();
+ }).toThrowError(
+ '`useRouter` is not supported in Server Components. You can use this hook if you convert the calling component to a Client Component.'
+ );
+ });
+});
diff --git a/packages/next-intl/src/navigation/react-server/createNavigation.tsx b/packages/next-intl/src/navigation/react-server/createNavigation.tsx
new file mode 100644
index 000000000..8e5f5c0bc
--- /dev/null
+++ b/packages/next-intl/src/navigation/react-server/createNavigation.tsx
@@ -0,0 +1,57 @@
+import {
+ RoutingConfigLocalizedNavigation,
+ RoutingConfigSharedNavigation
+} from '../../routing/config';
+import {
+ DomainsConfig,
+ LocalePrefixMode,
+ Locales,
+ Pathnames
+} from '../../routing/types';
+import {getRequestLocale} from '../../server/react-server/RequestLocale';
+import createSharedNavigationFns from '../shared/createSharedNavigationFns';
+
+export default function createNavigation<
+ const AppLocales extends Locales,
+ const AppLocalePrefixMode extends LocalePrefixMode = 'always',
+ const AppPathnames extends Pathnames = never,
+ const AppDomains extends DomainsConfig = never
+>(
+ routing?: [AppPathnames] extends [never]
+ ?
+ | RoutingConfigSharedNavigation<
+ AppLocales,
+ AppLocalePrefixMode,
+ AppDomains
+ >
+ | undefined
+ : RoutingConfigLocalizedNavigation<
+ AppLocales,
+ AppLocalePrefixMode,
+ AppPathnames,
+ AppDomains
+ >
+) {
+ type Locale = AppLocales extends never ? string : AppLocales[number];
+
+ function getLocale() {
+ return getRequestLocale() as Locale;
+ }
+
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const {config, ...fns} = createSharedNavigationFns(getLocale, routing);
+
+ function notSupported(hookName: string) {
+ return () => {
+ throw new Error(
+ `\`${hookName}\` is not supported in Server Components. You can use this hook if you convert the calling component to a Client Component.`
+ );
+ };
+ }
+
+ return {
+ ...fns,
+ usePathname: notSupported('usePathname'),
+ useRouter: notSupported('useRouter')
+ };
+}
diff --git a/packages/next-intl/src/navigation/react-server/createSharedPathnamesNavigation.tsx b/packages/next-intl/src/navigation/react-server/createSharedPathnamesNavigation.tsx
index deaf31468..a9da79401 100644
--- a/packages/next-intl/src/navigation/react-server/createSharedPathnamesNavigation.tsx
+++ b/packages/next-intl/src/navigation/react-server/createSharedPathnamesNavigation.tsx
@@ -3,14 +3,22 @@ import {
receiveLocalePrefixConfig,
RoutingConfigSharedNavigation
} from '../../routing/config';
-import {Locales} from '../../routing/types';
+import {DomainsConfig, LocalePrefixMode, Locales} from '../../routing/types';
import {ParametersExceptFirst} from '../../shared/types';
import ServerLink from './ServerLink';
import {serverPermanentRedirect, serverRedirect} from './redirects';
export default function createSharedPathnamesNavigation<
- AppLocales extends Locales
->(routing?: RoutingConfigSharedNavigation) {
+ AppLocales extends Locales,
+ AppLocalePrefixMode extends LocalePrefixMode,
+ AppDomains extends DomainsConfig = never
+>(
+ routing?: RoutingConfigSharedNavigation<
+ AppLocales,
+ AppLocalePrefixMode,
+ AppDomains
+ >
+) {
const localePrefix = receiveLocalePrefixConfig(routing?.localePrefix);
function notSupported(hookName: string) {
@@ -23,11 +31,16 @@ export default function createSharedPathnamesNavigation<
function Link(
props: Omit<
- ComponentProps>,
- 'localePrefix' | 'locales'
+ ComponentProps>,
+ 'localePrefix'
>
) {
- return localePrefix={localePrefix} {...props} />;
+ return (
+
+ localePrefix={localePrefix}
+ {...props}
+ />
+ );
}
function redirect(
diff --git a/packages/next-intl/src/navigation/react-server/index.tsx b/packages/next-intl/src/navigation/react-server/index.tsx
index fc1e780c1..88636a437 100644
--- a/packages/next-intl/src/navigation/react-server/index.tsx
+++ b/packages/next-intl/src/navigation/react-server/index.tsx
@@ -1,3 +1,4 @@
export {default as createSharedPathnamesNavigation} from './createSharedPathnamesNavigation';
export {default as createLocalizedPathnamesNavigation} from './createLocalizedPathnamesNavigation';
+export {default as createNavigation} from './createNavigation';
export type {Pathnames} from '../../routing/types';
diff --git a/packages/next-intl/src/navigation/shared/BaseLink.tsx b/packages/next-intl/src/navigation/shared/BaseLink.tsx
index 1d5fafb2f..7ce46d92c 100644
--- a/packages/next-intl/src/navigation/shared/BaseLink.tsx
+++ b/packages/next-intl/src/navigation/shared/BaseLink.tsx
@@ -4,61 +4,58 @@ import NextLink from 'next/link';
import {usePathname} from 'next/navigation';
import React, {
ComponentProps,
- MouseEvent,
forwardRef,
+ MouseEvent,
useEffect,
useState
} from 'react';
import useLocale from '../../react-client/useLocale';
-import {LocalePrefixMode} from '../../routing/types';
-import {isLocalizableHref, localizeHref, prefixHref} from '../../shared/utils';
import syncLocaleCookie from './syncLocaleCookie';
type Props = Omit, 'locale'> & {
- locale: string;
- prefix: string;
- localePrefixMode: LocalePrefixMode;
+ locale?: string;
+ defaultLocale?: string;
+ /** Special case for `localePrefix: 'as-needed'` and `domains`. */
+ unprefixed?: {
+ domains: {[domain: string]: string};
+ pathname: string;
+ };
};
function BaseLink(
- {href, locale, localePrefixMode, onClick, prefetch, prefix, ...rest}: Props,
- ref: Props['ref']
+ {defaultLocale, href, locale, onClick, prefetch, unprefixed, ...rest}: Props,
+ ref: ComponentProps['ref']
) {
- // The types aren't entirely correct here. Outside of Next.js
- // `useParams` can be called, but the return type is `null`.
- const pathname = usePathname() as ReturnType | null;
-
const curLocale = useLocale();
const isChangingLocale = locale !== curLocale;
+ const linkLocale = locale || curLocale;
+ const host = useHost();
- const [localizedHref, setLocalizedHref] = useState(() =>
- isLocalizableHref(href) &&
- (localePrefixMode !== 'never' || isChangingLocale)
- ? // For the `localePrefix: 'as-needed' strategy, the href shouldn't
- // be prefixed if the locale is the default locale. To determine this, we
- // need a) the default locale and b) the information if we use prefixed
- // routing. The default locale can vary by domain, therefore during the
- // RSC as well as the SSR render, we can't determine the default locale
- // statically. Therefore we always prefix the href since this will
- // always result in a valid URL, even if it might cause a redirect. This
- // is better than pointing to a non-localized href during the server
- // render, which would potentially be wrong. The final href is
- // determined in the effect below.
- prefixHref(href, prefix)
- : href
- );
+ const finalHref =
+ // Only after hydration (to avoid mismatches)
+ host &&
+ // If there is an `unprefixed` prop, the
+ // `defaultLocale` might differ by domain
+ unprefixed &&
+ // Unprefix the pathname if a domain matches
+ (unprefixed.domains[host] === linkLocale ||
+ // … and handle unknown domains by applying the
+ // global `defaultLocale` (e.g. on localhost)
+ (!Object.keys(unprefixed.domains).includes(host) &&
+ curLocale === defaultLocale &&
+ !locale))
+ ? unprefixed.pathname
+ : href;
+
+ // The types aren't entirely correct here. Outside of Next.js
+ // `useParams` can be called, but the return type is `null`.
+ const pathname = usePathname() as ReturnType | null;
function onLinkClick(event: MouseEvent) {
syncLocaleCookie(pathname, curLocale, locale);
if (onClick) onClick(event);
}
- useEffect(() => {
- if (!pathname) return;
-
- setLocalizedHref(localizeHref(href, locale, curLocale, pathname, prefix));
- }, [curLocale, href, locale, pathname, prefix]);
-
if (isChangingLocale) {
if (prefetch && process.env.NODE_ENV !== 'production') {
console.error(
@@ -71,7 +68,7 @@ function BaseLink(
return (
();
+
+ useEffect(() => {
+ setHost(window.location.host);
+ }, []);
+
+ return host;
+}
+
+export default forwardRef(BaseLink);
diff --git a/packages/next-intl/src/navigation/shared/LegacyBaseLink.tsx b/packages/next-intl/src/navigation/shared/LegacyBaseLink.tsx
new file mode 100644
index 000000000..7c96aafe3
--- /dev/null
+++ b/packages/next-intl/src/navigation/shared/LegacyBaseLink.tsx
@@ -0,0 +1,56 @@
+'use client';
+
+import NextLink from 'next/link';
+import {usePathname} from 'next/navigation';
+import React, {ComponentProps, forwardRef, useEffect, useState} from 'react';
+import useLocale from '../../react-client/useLocale';
+import {LocalePrefixMode} from '../../routing/types';
+import {isLocalizableHref, localizeHref, prefixHref} from '../../shared/utils';
+import BaseLink from './BaseLink';
+
+type Props = Omit, 'locale'> & {
+ locale: string;
+ prefix: string;
+ localePrefixMode: LocalePrefixMode;
+};
+
+function LegacyBaseLink(
+ {href, locale, localePrefixMode, prefix, ...rest}: Props,
+ ref: Props['ref']
+) {
+ // The types aren't entirely correct here. Outside of Next.js
+ // `useParams` can be called, but the return type is `null`.
+ const pathname = usePathname() as ReturnType | null;
+
+ const curLocale = useLocale();
+ const isChangingLocale = locale !== curLocale;
+
+ const [localizedHref, setLocalizedHref] = useState(() =>
+ isLocalizableHref(href) &&
+ (localePrefixMode !== 'never' || isChangingLocale)
+ ? // For the `localePrefix: 'as-needed' strategy, the href shouldn't
+ // be prefixed if the locale is the default locale. To determine this, we
+ // need a) the default locale and b) the information if we use prefixed
+ // routing. The default locale can vary by domain, therefore during the
+ // RSC as well as the SSR render, we can't determine the default locale
+ // statically. Therefore we always prefix the href since this will
+ // always result in a valid URL, even if it might cause a redirect. This
+ // is better than pointing to a non-localized href during the server
+ // render, which would potentially be wrong. The final href is
+ // determined in the effect below.
+ prefixHref(href, prefix)
+ : href
+ );
+
+ useEffect(() => {
+ if (!pathname) return;
+
+ setLocalizedHref(localizeHref(href, locale, curLocale, pathname, prefix));
+ }, [curLocale, href, locale, pathname, prefix]);
+
+ return ;
+}
+
+const LegacyBaseLinkWithRef = forwardRef(LegacyBaseLink);
+(LegacyBaseLinkWithRef as any).displayName = 'ClientLink';
+export default LegacyBaseLinkWithRef;
diff --git a/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx b/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx
new file mode 100644
index 000000000..9b6d9ea65
--- /dev/null
+++ b/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx
@@ -0,0 +1,262 @@
+import {
+ permanentRedirect as nextPermanentRedirect,
+ redirect as nextRedirect
+} from 'next/navigation';
+import React, {ComponentProps, forwardRef, use} from 'react';
+import {
+ receiveRoutingConfig,
+ RoutingConfigLocalizedNavigation,
+ RoutingConfigSharedNavigation
+} from '../../routing/config';
+import {
+ DomainConfig,
+ DomainsConfig,
+ LocalePrefixMode,
+ Locales,
+ Pathnames
+} from '../../routing/types';
+import {ParametersExceptFirst, Prettify} from '../../shared/types';
+import {isLocalizableHref} from '../../shared/utils';
+import BaseLink from './BaseLink';
+import {
+ HrefOrHrefWithParams,
+ HrefOrUrlObjectWithParams,
+ QueryParams,
+ applyPathnamePrefix,
+ compileLocalizedPathname,
+ normalizeNameOrNameWithParams,
+ serializeSearchParams,
+ validateReceivedConfig
+} from './utils';
+
+type PromiseOrValue = Type | Promise;
+type UnwrapPromiseOrValue =
+ Type extends Promise ? Value : Type;
+
+/**
+ * Shared implementations for `react-server` and `react-client`
+ */
+export default function createSharedNavigationFns<
+ const AppLocales extends Locales,
+ const AppPathnames extends Pathnames = never,
+ const AppLocalePrefixMode extends LocalePrefixMode = 'always',
+ const AppDomains extends DomainsConfig = never
+>(
+ getLocale: () => PromiseOrValue<
+ AppLocales extends never ? string : AppLocales[number]
+ >,
+ routing?: [AppPathnames] extends [never]
+ ?
+ | RoutingConfigSharedNavigation<
+ AppLocales,
+ AppLocalePrefixMode,
+ AppDomains
+ >
+ | undefined
+ : RoutingConfigLocalizedNavigation<
+ AppLocales,
+ AppLocalePrefixMode,
+ AppPathnames,
+ AppDomains
+ >
+) {
+ type Locale = UnwrapPromiseOrValue>;
+
+ const config = receiveRoutingConfig(routing || {});
+ if (process.env.NODE_ENV !== 'production') {
+ validateReceivedConfig(config);
+ }
+
+ const pathnames = (config as any).pathnames as [AppPathnames] extends [never]
+ ? undefined
+ : AppPathnames;
+
+ // This combination requires that the current host is known in order to
+ // compute a correct pathname. Since that can only be achieved by reading from
+ // headers, this would break static rendering. Therefore, as a workaround we
+ // always add a prefix in this case to be on the safe side. The downside is
+ // that the user might get redirected again if the middleware detects that the
+ // prefix is not needed.
+ const forcePrefixSsr =
+ (config.localePrefix.mode === 'as-needed' && (config as any).domains) ||
+ undefined;
+
+ type LinkProps = Prettify<
+ Omit<
+ ComponentProps,
+ 'href' | 'localePrefix' | 'unprefixed' | 'defaultLocale'
+ > & {
+ /** @see https://next-intl-docs.vercel.app/docs/routing/navigation#link */
+ href: [AppPathnames] extends [never]
+ ? ComponentProps['href']
+ : HrefOrUrlObjectWithParams;
+ /** @see https://next-intl-docs.vercel.app/docs/routing/navigation#link */
+ locale?: string;
+ }
+ >;
+ function Link(
+ {href, locale, ...rest}: LinkProps,
+ ref: ComponentProps['ref']
+ ) {
+ let pathname, params;
+ if (typeof href === 'object') {
+ pathname = href.pathname;
+ // @ts-expect-error -- This is ok
+ params = href.params;
+ } else {
+ pathname = href;
+ }
+
+ // @ts-expect-error -- This is ok
+ const isLocalizable = isLocalizableHref(href);
+
+ const localePromiseOrValue = getLocale();
+ const curLocale =
+ localePromiseOrValue instanceof Promise
+ ? use(localePromiseOrValue)
+ : localePromiseOrValue;
+
+ const finalPathname = isLocalizable
+ ? getPathname(
+ // @ts-expect-error -- This is ok
+ {
+ locale: locale || curLocale,
+ href: pathnames == null ? pathname : {pathname, params}
+ },
+ locale != null || forcePrefixSsr || undefined
+ )
+ : pathname;
+
+ return (
+ ,
+ domain: DomainConfig
+ ) => {
+ // @ts-expect-error -- This is ok
+ acc[domain.domain] = domain.defaultLocale;
+ return acc;
+ },
+ {}
+ ),
+ pathname: getPathname(
+ // @ts-expect-error -- This is ok
+ {
+ locale: curLocale,
+ href: pathnames == null ? pathname : {pathname, params}
+ },
+ false
+ )
+ }
+ : undefined
+ }
+ {...rest}
+ />
+ );
+ }
+ const LinkWithRef = forwardRef(Link);
+
+ type DomainConfigForAsNeeded = typeof routing extends undefined
+ ? {}
+ : AppLocalePrefixMode extends 'as-needed'
+ ? [AppDomains] extends [never]
+ ? {}
+ : {
+ /**
+ * In case you're using `localePrefix: 'as-needed'` in combination with `domains`, the `defaultLocale` can differ by domain and therefore the locales that need to be prefixed can differ as well. For this particular case, this parameter should be provided in order to compute the correct pathname. Note that the actual domain is not part of the result, but only the pathname is returned.
+ * @see https://next-intl-docs.vercel.app/docs/routing/navigation#getpathname
+ */
+ domain: AppDomains[number]['domain'];
+ }
+ : {};
+
+ function getPathname(
+ args: {
+ /** @see https://next-intl-docs.vercel.app/docs/routing/navigation#getpathname */
+ href: [AppPathnames] extends [never]
+ ? string | {pathname: string; query?: QueryParams}
+ : HrefOrHrefWithParams;
+ locale: string;
+ } & DomainConfigForAsNeeded,
+ /** @private Removed in types returned below */
+ _forcePrefix?: boolean
+ ) {
+ const {href, locale} = args;
+
+ let pathname: string;
+ if (pathnames == null) {
+ if (typeof href === 'object') {
+ pathname = href.pathname as string;
+ if (href.query) {
+ pathname += serializeSearchParams(href.query);
+ }
+ } else {
+ pathname = href as string;
+ }
+ } else {
+ pathname = compileLocalizedPathname({
+ locale,
+ // @ts-expect-error -- This is ok
+ ...normalizeNameOrNameWithParams(href),
+ // @ts-expect-error -- This is ok
+ pathnames: config.pathnames
+ });
+ }
+
+ return applyPathnamePrefix(
+ pathname,
+ locale,
+ config,
+ // @ts-expect-error -- This is ok
+ args.domain,
+ _forcePrefix
+ );
+ }
+
+ function getRedirectFn(
+ fn: typeof nextRedirect | typeof nextPermanentRedirect
+ ) {
+ /** @see https://next-intl-docs.vercel.app/docs/routing/navigation#redirect */
+ return function redirectFn(
+ args: Omit[0], 'domain'> &
+ Partial,
+ ...rest: ParametersExceptFirst
+ ) {
+ return fn(
+ // @ts-expect-error -- We're forcing the prefix when no domain is provided
+ getPathname(args, args.domain ? undefined : forcePrefixSsr),
+ ...rest
+ );
+ };
+ }
+
+ const redirect = getRedirectFn(nextRedirect);
+ const permanentRedirect = getRedirectFn(nextPermanentRedirect);
+
+ return {
+ config,
+ Link: LinkWithRef,
+ redirect,
+ permanentRedirect,
+
+ // Remove `_forcePrefix` from public API
+ getPathname: getPathname as (
+ args: Parameters[0]
+ ) => string
+ };
+}
diff --git a/packages/next-intl/src/navigation/shared/redirects.tsx b/packages/next-intl/src/navigation/shared/redirects.tsx
index 95a53a6f5..96e135ec4 100644
--- a/packages/next-intl/src/navigation/shared/redirects.tsx
+++ b/packages/next-intl/src/navigation/shared/redirects.tsx
@@ -2,7 +2,11 @@ import {
permanentRedirect as nextPermanentRedirect,
redirect as nextRedirect
} from 'next/navigation';
-import {Locales, LocalePrefixConfigVerbose} from '../../routing/types';
+import {
+ Locales,
+ LocalePrefixConfigVerbose,
+ LocalePrefixMode
+} from '../../routing/types';
import {ParametersExceptFirst} from '../../shared/types';
import {
getLocalePrefix,
@@ -11,20 +15,27 @@ import {
} from '../../shared/utils';
function createRedirectFn(redirectFn: typeof nextRedirect) {
- return function baseRedirect(
+ return function baseRedirect<
+ AppLocales extends Locales,
+ AppLocalePrefixMode extends LocalePrefixMode
+ >(
params: {
pathname: string;
locale: Locales[number];
- localePrefix: LocalePrefixConfigVerbose;
+ localePrefix: LocalePrefixConfigVerbose;
},
...args: ParametersExceptFirst
) {
const prefix = getLocalePrefix(params.locale, params.localePrefix);
+
+ // This logic is considered legacy and is replaced by `applyPathnamePrefix`.
+ // We keep it this way for now for backwards compatibility.
const localizedPathname =
params.localePrefix.mode === 'never' ||
!isLocalizableHref(params.pathname)
? params.pathname
: prefixPathname(prefix, params.pathname);
+
return redirectFn(localizedPathname, ...args);
};
}
diff --git a/packages/next-intl/src/navigation/shared/utils.tsx b/packages/next-intl/src/navigation/shared/utils.tsx
index b5cb714da..226643237 100644
--- a/packages/next-intl/src/navigation/shared/utils.tsx
+++ b/packages/next-intl/src/navigation/shared/utils.tsx
@@ -1,10 +1,19 @@
import type {ParsedUrlQueryInput} from 'node:querystring';
import type {UrlObject} from 'url';
-import {Locales, Pathnames} from '../../routing/types';
+import {ResolvedRoutingConfig} from '../../routing/config';
+import {
+ DomainsConfig,
+ LocalePrefixMode,
+ Locales,
+ Pathnames
+} from '../../routing/types';
import {
matchesPathname,
getSortedPathnames,
- normalizeTrailingSlash
+ normalizeTrailingSlash,
+ isLocalizableHref,
+ prefixPathname,
+ getLocalePrefix
} from '../../shared/utils';
import StrictParams from './StrictParams';
@@ -22,24 +31,37 @@ type HrefOrHrefWithParamsImpl =
: // No params
Pathname | ({pathname: Pathname} & Other);
+// For `Link`
export type HrefOrUrlObjectWithParams = HrefOrHrefWithParamsImpl<
Pathname,
Omit
>;
+export type QueryParams = Record;
+
+// For `getPathname` (hence also its consumers: `redirect`, `useRouter`, …)
export type HrefOrHrefWithParams = HrefOrHrefWithParamsImpl<
Pathname,
- {query?: Record}
+ {query?: QueryParams}
>;
export function normalizeNameOrNameWithParams(
- href: HrefOrHrefWithParams
+ href:
+ | HrefOrHrefWithParams
+ | {
+ locale: string;
+ href: HrefOrHrefWithParams;
+ }
): {
pathname: Pathname;
params?: StrictParams;
} {
- // @ts-expect-error -- `extends string` in the generic unfortunately weakens the type
- return typeof href === 'string' ? {pathname: href as Pathname} : href;
+ return typeof href === 'string'
+ ? {pathname: href as Pathname}
+ : (href as {
+ pathname: Pathname;
+ params?: StrictParams;
+ });
}
export function serializeSearchParams(
@@ -102,6 +124,7 @@ export function compileLocalizedPathname({
function getNamedPath(value: keyof typeof pathnames) {
let namedPath = pathnames[value];
if (!namedPath) {
+ // Unknown pathnames
namedPath = value;
}
return namedPath;
@@ -198,3 +221,100 @@ export function getBasePath(
return windowPathname.replace(pathname, '');
}
}
+
+export function applyPathnamePrefix<
+ AppLocales extends Locales,
+ AppLocalePrefixMode extends LocalePrefixMode,
+ AppPathnames extends Pathnames | undefined,
+ AppDomains extends DomainsConfig | undefined
+>(
+ pathname: string,
+ locale: Locales[number],
+ routing: Pick<
+ ResolvedRoutingConfig<
+ AppLocales,
+ AppLocalePrefixMode,
+ AppPathnames,
+ AppDomains
+ >,
+ 'localePrefix' | 'domains'
+ > &
+ Partial<
+ Pick<
+ ResolvedRoutingConfig<
+ AppLocales,
+ AppLocalePrefixMode,
+ AppPathnames,
+ AppDomains
+ >,
+ 'defaultLocale'
+ >
+ >,
+ domain?: string,
+ force?: boolean
+): string {
+ const {mode} = routing.localePrefix;
+
+ let shouldPrefix;
+ if (force !== undefined) {
+ shouldPrefix = force;
+ } else if (isLocalizableHref(pathname)) {
+ if (mode === 'always') {
+ shouldPrefix = true;
+ } else if (mode === 'as-needed') {
+ let defaultLocale: AppLocales[number] | undefined = routing.defaultLocale;
+
+ if (routing.domains) {
+ const domainConfig = routing.domains.find(
+ (cur) => cur.domain === domain
+ );
+ if (domainConfig) {
+ defaultLocale = domainConfig.defaultLocale;
+ } else if (process.env.NODE_ENV !== 'production') {
+ if (!domain) {
+ console.error(
+ "You're using a routing configuration with `localePrefix: 'as-needed'` in combination with `domains`. In order to compute a correct pathname, you need to provide a `domain` parameter.\n\nSee: https://next-intl-docs.vercel.app/docs/routing#domains-localeprefix-asneeded"
+ );
+ } else {
+ // If a domain was provided, but it wasn't found in the routing
+ // configuration, this can be an indicator that the user is on
+ // localhost. In this case, we can simply use the domain-agnostic
+ // default locale.
+ }
+ }
+ }
+
+ shouldPrefix = defaultLocale !== locale;
+ }
+ }
+
+ return shouldPrefix
+ ? prefixPathname(getLocalePrefix(locale, routing.localePrefix), pathname)
+ : pathname;
+}
+
+export function validateReceivedConfig<
+ AppLocales extends Locales,
+ AppLocalePrefixMode extends LocalePrefixMode,
+ AppPathnames extends Pathnames | undefined,
+ AppDomains extends DomainsConfig | undefined
+>(
+ config: Partial<
+ Pick<
+ ResolvedRoutingConfig<
+ AppLocales,
+ AppLocalePrefixMode,
+ AppPathnames,
+ AppDomains
+ >,
+ 'defaultLocale' | 'localePrefix'
+ >
+ >
+) {
+ if (
+ config.localePrefix?.mode === 'as-needed' &&
+ !('defaultLocale' in config)
+ ) {
+ throw new Error("`localePrefix: 'as-needed' requires a `defaultLocale`.");
+ }
+}
diff --git a/packages/next-intl/src/react-client/index.tsx b/packages/next-intl/src/react-client/index.tsx
index a2f4f5ee8..86fd21d3b 100644
--- a/packages/next-intl/src/react-client/index.tsx
+++ b/packages/next-intl/src/react-client/index.tsx
@@ -15,7 +15,6 @@ import {
export * from 'use-intl';
-// eslint-disable-next-line @typescript-eslint/ban-types
function callHook(name: string, hook: Function) {
return (...args: Array) => {
try {
diff --git a/packages/next-intl/src/react-server/index.test.tsx b/packages/next-intl/src/react-server/index.test.tsx
index a358d784b..c8d435322 100644
--- a/packages/next-intl/src/react-server/index.test.tsx
+++ b/packages/next-intl/src/react-server/index.test.tsx
@@ -15,6 +15,7 @@ vi.mock('react');
vi.mock('../../src/server/react-server/createRequestConfig', () => ({
default: async () => ({
+ locale: 'en',
messages: {
Component: {
title: 'Title'
@@ -26,10 +27,6 @@ vi.mock('../../src/server/react-server/createRequestConfig', () => ({
})
}));
-vi.mock('../../src/server/react-server/RequestLocale', () => ({
- getRequestLocale: vi.fn(() => 'en')
-}));
-
vi.mock('use-intl/core', async (importActual) => {
const actual: any = await importActual();
return {
diff --git a/packages/next-intl/src/react-server/useTranslations.test.tsx b/packages/next-intl/src/react-server/useTranslations.test.tsx
index 396841934..ebf6cef67 100644
--- a/packages/next-intl/src/react-server/useTranslations.test.tsx
+++ b/packages/next-intl/src/react-server/useTranslations.test.tsx
@@ -5,6 +5,7 @@ import {createTranslator, useTranslations} from '.';
vi.mock('../../src/server/react-server/createRequestConfig', () => ({
default: async () => ({
+ locale: 'en',
messages: {
A: {
title: 'A'
@@ -19,10 +20,6 @@ vi.mock('../../src/server/react-server/createRequestConfig', () => ({
})
}));
-vi.mock('../../src/server/react-server/RequestLocale', () => ({
- getRequestLocale: vi.fn(() => 'en')
-}));
-
vi.mock('react');
vi.mock('use-intl/core', async (importActual) => {
diff --git a/packages/next-intl/src/routing/config.tsx b/packages/next-intl/src/routing/config.tsx
index 3a541ffc2..6bdcf067c 100644
--- a/packages/next-intl/src/routing/config.tsx
+++ b/packages/next-intl/src/routing/config.tsx
@@ -3,12 +3,15 @@ import {
LocalePrefix,
LocalePrefixConfigVerbose,
DomainsConfig,
- Pathnames
+ Pathnames,
+ LocalePrefixMode
} from './types';
export type RoutingConfig<
AppLocales extends Locales,
- AppPathnames extends Pathnames
+ AppLocalePrefixMode extends LocalePrefixMode,
+ AppPathnames extends Pathnames | undefined,
+ AppDomains extends DomainsConfig | undefined
> = {
/**
* All available locales.
@@ -26,16 +29,15 @@ export type RoutingConfig<
* Configures whether and which prefix is shown for a given locale.
* @see https://next-intl-docs.vercel.app/docs/routing#locale-prefix
**/
- localePrefix?: LocalePrefix;
+ localePrefix?: LocalePrefix;
/**
* Can be used to change the locale handling per domain.
* @see https://next-intl-docs.vercel.app/docs/routing#domains
**/
- domains?: DomainsConfig;
+ domains?: AppDomains;
} & ([AppPathnames] extends [never]
? // https://discord.com/channels/997886693233393714/1278008400533520434
- // eslint-disable-next-line @typescript-eslint/ban-types
{}
: {
/**
@@ -47,48 +49,71 @@ export type RoutingConfig<
export type RoutingConfigSharedNavigation<
AppLocales extends Locales,
- AppPathnames extends Pathnames
+ AppLocalePrefixMode extends LocalePrefixMode,
+ AppDomains extends DomainsConfig = never
> = Omit<
- RoutingConfig,
+ RoutingConfig,
'defaultLocale' | 'locales' | 'pathnames'
> &
Partial<
- Pick, 'defaultLocale' | 'locales'>
+ Pick<
+ RoutingConfig,
+ 'defaultLocale' | 'locales'
+ >
>;
export type RoutingConfigLocalizedNavigation<
AppLocales extends Locales,
- AppPathnames extends Pathnames
+ AppLocalePrefixMode extends LocalePrefixMode,
+ AppPathnames extends Pathnames,
+ AppDomains extends DomainsConfig = never
> = Omit<
- RoutingConfig,
+ RoutingConfig,
'defaultLocale' | 'pathnames'
> &
- Partial, 'defaultLocale'>> & {
+ Partial<
+ Pick<
+ RoutingConfig,
+ 'defaultLocale'
+ >
+ > & {
pathnames: AppPathnames;
};
export type ResolvedRoutingConfig<
AppLocales extends Locales,
- AppPathnames extends Pathnames = never
-> = Omit, 'localePrefix'> & {
- localePrefix: LocalePrefixConfigVerbose;
+ AppLocalePrefixMode extends LocalePrefixMode,
+ AppPathnames extends Pathnames | undefined,
+ AppDomains extends DomainsConfig | undefined
+> = Omit<
+ RoutingConfig,
+ 'localePrefix'
+> & {
+ localePrefix: LocalePrefixConfigVerbose;
};
export function receiveRoutingConfig<
AppLocales extends Locales,
- AppPathnames extends Pathnames,
- Config extends Partial>
+ AppLocalePrefixMode extends LocalePrefixMode,
+ AppPathnames extends Pathnames | undefined,
+ AppDomains extends DomainsConfig | undefined,
+ Config extends Partial<
+ RoutingConfig
+ >
>(input: Config) {
return {
- ...input,
- localePrefix: receiveLocalePrefixConfig(input.localePrefix)
+ ...(input as Omit),
+ localePrefix: receiveLocalePrefixConfig(input?.localePrefix)
};
}
-export function receiveLocalePrefixConfig(
- localePrefix?: LocalePrefix
-): LocalePrefixConfigVerbose {
- return typeof localePrefix === 'object'
- ? localePrefix
- : {mode: localePrefix || 'always'};
+export function receiveLocalePrefixConfig<
+ AppLocales extends Locales,
+ AppLocalePrefixMode extends LocalePrefixMode
+>(localePrefix?: LocalePrefix) {
+ return (
+ typeof localePrefix === 'object'
+ ? localePrefix
+ : {mode: localePrefix || 'always'}
+ ) as LocalePrefixConfigVerbose;
}
diff --git a/packages/next-intl/src/routing/defineRouting.tsx b/packages/next-intl/src/routing/defineRouting.tsx
index b035f5ffd..d470db867 100644
--- a/packages/next-intl/src/routing/defineRouting.tsx
+++ b/packages/next-intl/src/routing/defineRouting.tsx
@@ -1,9 +1,18 @@
import {RoutingConfig} from './config';
-import {Locales, Pathnames} from './types';
+import {DomainsConfig, LocalePrefixMode, Locales, Pathnames} from './types';
export default function defineRouting<
const AppLocales extends Locales,
- const AppPathnames extends Pathnames = never
->(config: RoutingConfig) {
+ const AppLocalePrefixMode extends LocalePrefixMode = 'always',
+ const AppPathnames extends Pathnames = never,
+ const AppDomains extends DomainsConfig = never
+>(
+ config: RoutingConfig<
+ AppLocales,
+ AppLocalePrefixMode,
+ AppPathnames,
+ AppDomains
+ >
+) {
return config;
}
diff --git a/packages/next-intl/src/routing/types.tsx b/packages/next-intl/src/routing/types.tsx
index 4e9b16368..d47c5ee2e 100644
--- a/packages/next-intl/src/routing/types.tsx
+++ b/packages/next-intl/src/routing/types.tsx
@@ -8,22 +8,29 @@ export type LocalePrefixes = Partial<
Record
>;
-export type LocalePrefixConfigVerbose =
- | {
+export type LocalePrefixConfigVerbose<
+ AppLocales extends Locales,
+ AppLocalePrefixMode extends LocalePrefixMode
+> = AppLocalePrefixMode extends 'always'
+ ? {
mode: 'always';
prefixes?: LocalePrefixes;
}
- | {
- mode: 'as-needed';
- prefixes?: LocalePrefixes;
- }
- | {
- mode: 'never';
- };
-
-export type LocalePrefix =
- | LocalePrefixMode
- | LocalePrefixConfigVerbose;
+ : AppLocalePrefixMode extends 'as-needed'
+ ? {
+ mode: 'as-needed';
+ prefixes?: LocalePrefixes;
+ }
+ : {
+ mode: 'never';
+ };
+
+export type LocalePrefix<
+ AppLocales extends Locales = [],
+ AppLocalePrefixMode extends LocalePrefixMode = 'always'
+> =
+ | AppLocalePrefixMode
+ | LocalePrefixConfigVerbose;
export type Pathnames = Record<
Pathname,
diff --git a/packages/next-intl/src/shared/types.tsx b/packages/next-intl/src/shared/types.tsx
index 2efd9d1a2..4e27dc0d1 100644
--- a/packages/next-intl/src/shared/types.tsx
+++ b/packages/next-intl/src/shared/types.tsx
@@ -12,3 +12,8 @@ export type ParametersExceptFirstTwo = Fn extends (
) => any
? R
: never;
+
+// https://www.totaltypescript.com/concepts/the-prettify-helper
+export type Prettify = {
+ [Key in keyof Type]: Type[Key];
+} & {};
diff --git a/packages/next-intl/src/shared/utils.tsx b/packages/next-intl/src/shared/utils.tsx
index 68adac5b0..58b92ac92 100644
--- a/packages/next-intl/src/shared/utils.tsx
+++ b/packages/next-intl/src/shared/utils.tsx
@@ -1,7 +1,11 @@
import {UrlObject} from 'url';
import NextLink from 'next/link';
import {ComponentProps} from 'react';
-import {Locales, LocalePrefixConfigVerbose} from '../routing/types';
+import {
+ Locales,
+ LocalePrefixConfigVerbose,
+ LocalePrefixMode
+} from '../routing/types';
type Href = ComponentProps['href'];
@@ -136,9 +140,12 @@ export function matchesPathname(
return regex.test(normalizedPathname);
}
-export function getLocalePrefix(
+export function getLocalePrefix<
+ AppLocales extends Locales,
+ AppLocalePrefixMode extends LocalePrefixMode
+>(
locale: AppLocales[number],
- localePrefix: LocalePrefixConfigVerbose
+ localePrefix: LocalePrefixConfigVerbose
) {
return (
(localePrefix.mode !== 'never' && localePrefix.prefixes?.[locale]) ||
diff --git a/packages/use-intl/.size-limit.ts b/packages/use-intl/.size-limit.ts
index 78561a217..c340a2828 100644
--- a/packages/use-intl/.size-limit.ts
+++ b/packages/use-intl/.size-limit.ts
@@ -2,20 +2,20 @@ import type {SizeLimitConfig} from 'size-limit';
const config: SizeLimitConfig = [
{
- name: './ (ESM)',
+ name: 'import * from \'use-intl\' (ESM)',
import: '*',
path: 'dist/esm/index.js',
limit: '14.085 kB'
},
{
- name: './ (no useTranslations, ESM)',
+ name: 'import {IntlProvider, useLocale, useNow, useTimeZone, useMessages, useFormatter} from \'use-intl\' (ESM)',
path: 'dist/esm/index.js',
import:
'{IntlProvider, useLocale, useNow, useTimeZone, useMessages, useFormatter}',
limit: '2.865 kB'
},
{
- name: './ (CJS)',
+ name: 'import * from \'use-intl\' (CJS)',
path: 'dist/production/index.js',
limit: '15.65 kB'
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index dd8c09cd7..840f45700 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -131,7 +131,7 @@ importers:
version: 2.1.1
next:
specifier: ^14.2.4
- version: 14.2.4(@babel/core@7.24.8)(@playwright/test@1.44.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ version: 14.2.4(@babel/core@7.24.7)(@playwright/test@1.44.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
next-intl:
specifier: ^3.0.0
version: link:../../packages/next-intl
@@ -322,6 +322,9 @@ importers:
'@mdx-js/react':
specifier: ^3.0.1
version: 3.0.1(@types/react@18.3.3)(react@18.3.1)
+ '@radix-ui/react-dropdown-menu':
+ specifier: ^2.1.1
+ version: 2.1.1(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
lodash:
specifier: ^4.17.21
version: 4.17.21
@@ -1346,6 +1349,7 @@ packages:
'@babel/plugin-proposal-async-generator-functions@7.20.7':
resolution: {integrity: sha512-xMbiLsn/8RK7Wq7VeVytytS2L6qE69bXPB10YCmMdDZbKF4okCqY74pI/jJQ/8U0b/F6NrT2+14b8/P9/3AMGA==}
engines: {node: '>=6.9.0'}
+ deprecated: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-async-generator-functions instead.
peerDependencies:
'@babel/core': ^7.0.0-0
@@ -1371,24 +1375,28 @@ packages:
'@babel/plugin-proposal-nullish-coalescing-operator@7.18.6':
resolution: {integrity: sha512-wQxQzxYeJqHcfppzBDnm1yAY0jSRkUXR2z8RePZYrKwMKgMlE8+Z6LUno+bd6LvbGh8Gltvy74+9pIYkr+XkKA==}
engines: {node: '>=6.9.0'}
+ deprecated: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-nullish-coalescing-operator instead.
peerDependencies:
'@babel/core': ^7.0.0-0
'@babel/plugin-proposal-object-rest-spread@7.20.7':
resolution: {integrity: sha512-d2S98yCiLxDVmBmE8UjGcfPvNEUbA1U5q5WxaWFUGRzJSVAZqm5W6MbPct0jxnegUZ0niLeNX+IOzEs7wYg9Dg==}
engines: {node: '>=6.9.0'}
+ deprecated: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-object-rest-spread instead.
peerDependencies:
'@babel/core': ^7.0.0-0
'@babel/plugin-proposal-optional-catch-binding@7.18.6':
resolution: {integrity: sha512-Q40HEhs9DJQyaZfUjjn6vE8Cv4GmMHCYuMGIWUnlxH6400VGxOuwWsPt4FxXxJkC/5eOzgn0z21M9gMT4MOhbw==}
engines: {node: '>=6.9.0'}
+ deprecated: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-optional-catch-binding instead.
peerDependencies:
'@babel/core': ^7.0.0-0
'@babel/plugin-proposal-optional-chaining@7.21.0':
resolution: {integrity: sha512-p4zeefM72gpmEe2fkUr/OnOXpWEf8nAgk7ZYVqqfFiyIG7oFfVZcCrU64hWn5xp4tQ9LkV4bTIa5rD0KANpKNA==}
engines: {node: '>=6.9.0'}
+ deprecated: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-optional-chaining instead.
peerDependencies:
'@babel/core': ^7.0.0-0
@@ -2819,6 +2827,7 @@ packages:
'@humanwhocodes/config-array@0.11.13':
resolution: {integrity: sha512-JSBDMiDKSzQVngfRjOdFXgFfklaXI4K9nLF49Auh21lmBWRLIK3+xTErTWD4KU54pb6coM6ESE7Awz/FNU3zgQ==}
engines: {node: '>=10.10.0'}
+ deprecated: Use @eslint/config-array instead
'@humanwhocodes/module-importer@1.0.1':
resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==}
@@ -2826,6 +2835,7 @@ packages:
'@humanwhocodes/object-schema@2.0.1':
resolution: {integrity: sha512-dvuCeX5fC9dXgJn9t+X5atfmgQAzUOWqS1254Gh0m6i8wKd10ebXkfNKiRK+1GWi/yTvvLDHpoxLr0xxxeslWw==}
+ deprecated: Use @eslint/object-schema instead
'@hutson/parse-repository-url@5.0.0':
resolution: {integrity: sha512-e5+YUKENATs1JgYHMzTr2MW/NDcXGfYFAuOQU8gJgF/kEh4EqKgfGrfLI67bMD4tbhZVlkigz/9YYwWcbOFthg==}
@@ -3639,6 +3649,19 @@ packages:
'@types/react-dom':
optional: true
+ '@radix-ui/react-dropdown-menu@2.1.1':
+ resolution: {integrity: sha512-y8E+x9fBq9qvteD2Zwa4397pUVhYsh9iq44b5RD5qu1GMJWBCBuVg1hMyItbc6+zH00TxGRqd9Iot4wzf3OoBQ==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
'@radix-ui/react-focus-guards@1.1.0':
resolution: {integrity: sha512-w6XZNUPVv6xCpZUqb/yN9DL6auvpGX3C/ee6Hdi16v2UUy25HV2Q5bcflsiDyT/g5RwbPQ/GIT1vLkeRb+ITBw==}
peerDependencies:
@@ -3670,6 +3693,19 @@ packages:
'@types/react':
optional: true
+ '@radix-ui/react-menu@2.1.1':
+ resolution: {integrity: sha512-oa3mXRRVjHi6DZu/ghuzdylyjaMXLymx83irM7hTxutQbD+7IhPKdMdRHD26Rm+kHRrWcrUkkRPv5pd47a2xFQ==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
'@radix-ui/react-popper@1.2.0':
resolution: {integrity: sha512-ZnRMshKF43aBxVWPWvbj21+7TQCvhuULWJ4gNIKYpRlQt5xGRhLx66tMp8pya2UkGHTSlhpXwmjqltDYHhw7Vg==}
peerDependencies:
@@ -3696,6 +3732,19 @@ packages:
'@types/react-dom':
optional: true
+ '@radix-ui/react-presence@1.1.0':
+ resolution: {integrity: sha512-Gq6wuRN/asf9H/E/VzdKoUtT8GC9PQc9z40/vEr0VCJ4u5XvvhWIrSsCB6vD2/cH7ugTdSfYq9fLJCcM00acrQ==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
'@radix-ui/react-primitive@2.0.0':
resolution: {integrity: sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==}
peerDependencies:
@@ -3709,6 +3758,19 @@ packages:
'@types/react-dom':
optional: true
+ '@radix-ui/react-roving-focus@1.1.0':
+ resolution: {integrity: sha512-EA6AMGeq9AEeQDeSH0aZgG198qkfHSbvWTf1HvoDmOB5bBG/qTxjYMWUKMnYiV6J/iP/J8MEFSuB2zRU2n7ODA==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
'@radix-ui/react-select@2.1.1':
resolution: {integrity: sha512-8iRDfyLtzxlprOo9IicnzvpsO1wNCkuwzzCM+Z5Rb5tNOpCdMvcc2AkzX0Fz+Tz9v6NJ5B/7EEgyZveo4FBRfQ==}
peerDependencies:
@@ -6324,6 +6386,7 @@ packages:
copy-concurrently@1.0.5:
resolution: {integrity: sha512-f2domd9fsVDFtaFcbaRZuYXwtdmnzqbADSwhSWYxYB/Q8zsdUUFMXVRwXGDMWmbEzAn1kdRrtI1T/KTFOL4X2A==}
+ deprecated: This package is no longer supported.
copy-descriptor@0.1.1:
resolution: {integrity: sha512-XgZ0pFcakEUlbwQEVNg3+QAis1FyTL3Qel9FYy8pSkQqoG3PNoT0bOCQtOXcOkur21r2Eq2kI+IE+gsmAEVlYw==}
@@ -7745,6 +7808,7 @@ packages:
figgy-pudding@3.5.2:
resolution: {integrity: sha512-0btnI/H8f2pavGMN8w40mlSKOfTK2SVJmBfBeVIj3kNw0swwgzyRq0d5TJVOwodFmtvpPeWPN/MCcfuWF0Ezbw==}
+ deprecated: This module is no longer supported.
figures@6.1.0:
resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==}
@@ -7979,6 +8043,7 @@ packages:
fs-write-stream-atomic@1.0.10:
resolution: {integrity: sha512-gehEzmPn2nAwr39eay+x3X34Ra+M2QlVUTLhkXPjWdeO8RF9kszk116avgBJM3ZyNHgHXBNx+VmPaFC36k0PzA==}
+ deprecated: This package is no longer supported.
fs.realpath@1.0.0:
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
@@ -8173,10 +8238,12 @@ packages:
glob@7.2.3:
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
+ deprecated: Glob versions prior to v9 are no longer supported
glob@8.1.0:
resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==}
engines: {node: '>=12'}
+ deprecated: Glob versions prior to v9 are no longer supported
global-modules@2.0.0:
resolution: {integrity: sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A==}
@@ -8653,6 +8720,7 @@ packages:
inflight@1.0.6:
resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==}
+ deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.
inherits@2.0.1:
resolution: {integrity: sha512-8nWq2nLTAwd02jTqJExUYFSD/fKq6VH9Y/oG2accc/kdI0V98Bag8d5a4gi3XHz73rDWa2PvTtvcWYquKqSENA==}
@@ -8744,10 +8812,12 @@ packages:
is-accessor-descriptor@0.1.6:
resolution: {integrity: sha512-e1BM1qnDbMRG3ll2U9dSK0UMHuWOs3pY3AtcFsmvwPtKL3MML/Q86i+GilLfvqEs4GW+ExB91tQ3Ig9noDIZ+A==}
engines: {node: '>=0.10.0'}
+ deprecated: Please upgrade to v0.1.7
is-accessor-descriptor@1.0.0:
resolution: {integrity: sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==}
engines: {node: '>=0.10.0'}
+ deprecated: Please upgrade to v1.0.1
is-alphabetical@2.0.1:
resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==}
@@ -8822,10 +8892,12 @@ packages:
is-data-descriptor@0.1.4:
resolution: {integrity: sha512-+w9D5ulSoBNlmw9OHn3U2v51SyoCd0he+bB3xMl62oijhrspxowjU+AIcDY0N3iEJbUEkB15IlMASQsxYigvXg==}
engines: {node: '>=0.10.0'}
+ deprecated: Please upgrade to v0.1.5
is-data-descriptor@1.0.0:
resolution: {integrity: sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==}
engines: {node: '>=0.10.0'}
+ deprecated: Please upgrade to v1.0.1
is-data-view@1.0.1:
resolution: {integrity: sha512-AHkaJrsUVW6wq6JS8y3JnM/GJF/9cf+k20+iDzlSaJrinEo5+7vRiteOSwBhHRiAyQATN1AmY4hwzxJKPmYf+w==}
@@ -10325,6 +10397,7 @@ packages:
move-concurrently@1.0.1:
resolution: {integrity: sha512-hdrFxZOycD/g6A6SoI2bB5NA/5NEqD0569+S47WZhPvm46sD50ZHdYaFmnua5lndde9rCHGjmfK7Z8BuCt/PcQ==}
+ deprecated: This package is no longer supported.
mri@1.2.0:
resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
@@ -10884,6 +10957,7 @@ packages:
osenv@0.1.5:
resolution: {integrity: sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g==}
+ deprecated: This package is no longer supported.
outdent@0.8.0:
resolution: {integrity: sha512-KiOAIsdpUTcAXuykya5fnVVT+/5uS0Q1mrkRHcF89tpieSmY33O/tmc54CqwA+bfhbtEfZUNLHaPUiB9X3jt1A==}
@@ -11686,6 +11760,10 @@ packages:
q@1.5.1:
resolution: {integrity: sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw==}
engines: {node: '>=0.6.0', teleport: '>=0.2.0'}
+ deprecated: |-
+ You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other.
+
+ (For a CapTP with native promises, see @endo/eventual-send and @endo/captp)
qrcode-terminal@0.11.0:
resolution: {integrity: sha512-Uu7ii+FQy4Qf82G4xu7ShHhjhGahEpCWc3x8UavY3CTcWV+ufmmCtwkr7ZKsX42jdL0kr1B5FKUeqJvAn51jzQ==}
@@ -12214,14 +12292,17 @@ packages:
rimraf@2.6.3:
resolution: {integrity: sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==}
+ deprecated: Rimraf versions prior to v4 are no longer supported
hasBin: true
rimraf@2.7.1:
resolution: {integrity: sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==}
+ deprecated: Rimraf versions prior to v4 are no longer supported
hasBin: true
rimraf@3.0.2:
resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==}
+ deprecated: Rimraf versions prior to v4 are no longer supported
hasBin: true
ripemd160@2.0.2:
@@ -18855,6 +18936,21 @@ snapshots:
'@types/react': 18.3.3
'@types/react-dom': 18.3.0
+ '@radix-ui/react-dropdown-menu@2.1.1(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.0
+ '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.3)(react@18.3.1)
+ '@radix-ui/react-context': 1.1.0(@types/react@18.3.3)(react@18.3.1)
+ '@radix-ui/react-id': 1.1.0(@types/react@18.3.3)(react@18.3.1)
+ '@radix-ui/react-menu': 2.1.1(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.3.3)(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.3
+ '@types/react-dom': 18.3.0
+
'@radix-ui/react-focus-guards@1.1.0(@types/react@18.3.3)(react@18.3.1)':
dependencies:
react: 18.3.1
@@ -18879,6 +18975,32 @@ snapshots:
optionalDependencies:
'@types/react': 18.3.3
+ '@radix-ui/react-menu@2.1.1(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.0
+ '@radix-ui/react-collection': 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.3)(react@18.3.1)
+ '@radix-ui/react-context': 1.1.0(@types/react@18.3.3)(react@18.3.1)
+ '@radix-ui/react-direction': 1.1.0(@types/react@18.3.3)(react@18.3.1)
+ '@radix-ui/react-dismissable-layer': 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-focus-guards': 1.1.0(@types/react@18.3.3)(react@18.3.1)
+ '@radix-ui/react-focus-scope': 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-id': 1.1.0(@types/react@18.3.3)(react@18.3.1)
+ '@radix-ui/react-popper': 1.2.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-portal': 1.1.1(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-presence': 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-roving-focus': 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-slot': 1.1.0(@types/react@18.3.3)(react@18.3.1)
+ '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.3.3)(react@18.3.1)
+ aria-hidden: 1.2.4
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ react-remove-scroll: 2.5.7(@types/react@18.3.3)(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.3
+ '@types/react-dom': 18.3.0
+
'@radix-ui/react-popper@1.2.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@floating-ui/react-dom': 2.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -18907,6 +19029,16 @@ snapshots:
'@types/react': 18.3.3
'@types/react-dom': 18.3.0
+ '@radix-ui/react-presence@1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.3)(react@18.3.1)
+ '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.3.3)(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.3
+ '@types/react-dom': 18.3.0
+
'@radix-ui/react-primitive@2.0.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/react-slot': 1.1.0(@types/react@18.3.3)(react@18.3.1)
@@ -18916,6 +19048,23 @@ snapshots:
'@types/react': 18.3.3
'@types/react-dom': 18.3.0
+ '@radix-ui/react-roving-focus@1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.0
+ '@radix-ui/react-collection': 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.3)(react@18.3.1)
+ '@radix-ui/react-context': 1.1.0(@types/react@18.3.3)(react@18.3.1)
+ '@radix-ui/react-direction': 1.1.0(@types/react@18.3.3)(react@18.3.1)
+ '@radix-ui/react-id': 1.1.0(@types/react@18.3.3)(react@18.3.1)
+ '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.3.3)(react@18.3.1)
+ '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.3.3)(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.3
+ '@types/react-dom': 18.3.0
+
'@radix-ui/react-select@2.1.1(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/number': 1.1.0