diff --git a/docs/pages/docs/routing/middleware.mdx b/docs/pages/docs/routing/middleware.mdx
index f85ce8b5d..f2c02c3d2 100644
--- a/docs/pages/docs/routing/middleware.mdx
+++ b/docs/pages/docs/routing/middleware.mdx
@@ -140,6 +140,8 @@ The bestmatching domain is detected based on these priorities:
### Locale prefix
+If you're using [the navigation APIs from `next-intl`](/docs/routing/navigation), you want to make sure your `localePrefix` setting matches your middleware configuration.
+
#### Always use a locale prefix [#locale-prefix-always]
By default, pathnames always start with the locale (e.g. `/en/about`).
@@ -200,7 +202,7 @@ In this case, requests for all locales will be rewritten to have the locale only
Note that [alternate links](#disable-alternate-links) are disabled in this
- mode since there are no distinct URLs per language.
+ mode since there might not be distinct URLs per locale.
### Disable automatic locale detection
diff --git a/docs/pages/docs/routing/navigation.mdx b/docs/pages/docs/routing/navigation.mdx
index bb0342c38..9c1b49772 100644
--- a/docs/pages/docs/routing/navigation.mdx
+++ b/docs/pages/docs/routing/navigation.mdx
@@ -39,19 +39,21 @@ To create [navigation APIs](#apis) for this strategy, use the `createSharedPathn
import {createSharedPathnamesNavigation} from 'next-intl/navigation';
export const locales = ['en', 'de'] as const;
+export const localePrefix = 'always'; // Default
export const {Link, redirect, usePathname, useRouter} =
- createSharedPathnamesNavigation({locales});
+ createSharedPathnamesNavigation({locales, localePrefix});
```
-The `locales` argument is identical to the configuration that you pass to the middleware. To reuse it there, you can import the `locales` into the middleware.
+The `locales` as well as the `localePrefix` argument is identical to the configuration that you pass to the middleware. You might want to share these values via a central configuration to keep them in sync.
```tsx filename="middleware.ts"
import createMiddleware from 'next-intl/middleware';
-import {locales} from './navigation';
+import {locales, localePrefix} from './navigation';
export default createMiddleware({
defaultLocale: 'en',
+ localePrefix,
locales
});
```
@@ -69,6 +71,7 @@ import {
} from 'next-intl/navigation';
export const locales = ['en', 'de'] as const;
+export const localePrefix = 'always'; // Default
// The `pathnames` object holds pairs of internal
// and external paths, separated by locale.
@@ -99,17 +102,18 @@ export const pathnames = {
} satisfies Pathnames;
export const {Link, redirect, usePathname, useRouter, getPathname} =
- createLocalizedPathnamesNavigation({locales, pathnames});
+ createLocalizedPathnamesNavigation({locales, localePrefix, pathnames});
```
-The `pathnames` argument is identical to the configuration that you pass to the middleware for [localizing pathnames](/docs/routing/middleware#localizing-pathnames). Because of this, you might want to import the `locales` and `pathnames` into the middleware.
+The arguments `locales`, `localePrefix` as well as `pathnames` are identical to the configuration that you pass to the middleware. You might want to share these values via a central configuration to make sure they stay in sync.
```tsx filename="middleware.ts"
import createMiddleware from 'next-intl/middleware';
-import {locales, pathnames} from './navigation';
+import {locales, localePrefix, pathnames} from './navigation';
export default createMiddleware({
defaultLocale: 'en',
+ localePrefix,
locales,
pathnames
});
diff --git a/examples/example-app-router-playground/src/middleware.ts b/examples/example-app-router-playground/src/middleware.ts
index 4006e7a92..c8954579c 100644
--- a/examples/example-app-router-playground/src/middleware.ts
+++ b/examples/example-app-router-playground/src/middleware.ts
@@ -1,9 +1,9 @@
import createMiddleware from 'next-intl/middleware';
-import {locales, pathnames} from './navigation';
+import {locales, pathnames, localePrefix} from './navigation';
export default createMiddleware({
defaultLocale: 'en',
- localePrefix: 'as-needed',
+ localePrefix,
pathnames,
locales
});
diff --git a/examples/example-app-router-playground/src/navigation.tsx b/examples/example-app-router-playground/src/navigation.tsx
index 6cf704620..d60fd630d 100644
--- a/examples/example-app-router-playground/src/navigation.tsx
+++ b/examples/example-app-router-playground/src/navigation.tsx
@@ -5,6 +5,8 @@ import {
export const locales = ['en', 'de', 'es'] as const;
+export const localePrefix = 'as-needed';
+
export const pathnames = {
'/': '/',
'/client': '/client',
@@ -25,5 +27,6 @@ export const pathnames = {
export const {Link, redirect, usePathname, useRouter} =
createLocalizedPathnamesNavigation({
locales,
+ localePrefix,
pathnames
});
diff --git a/examples/example-app-router/src/components/LocaleSwitcher.tsx b/examples/example-app-router/src/components/LocaleSwitcher.tsx
index 2b07d5a35..7a03cc182 100644
--- a/examples/example-app-router/src/components/LocaleSwitcher.tsx
+++ b/examples/example-app-router/src/components/LocaleSwitcher.tsx
@@ -1,5 +1,5 @@
import {useLocale, useTranslations} from 'next-intl';
-import {locales} from 'config';
+import {locales} from '../config';
import LocaleSwitcherSelect from './LocaleSwitcherSelect';
export default function LocaleSwitcher() {
diff --git a/examples/example-app-router/src/config.ts b/examples/example-app-router/src/config.ts
index 626127125..934f7d37f 100644
--- a/examples/example-app-router/src/config.ts
+++ b/examples/example-app-router/src/config.ts
@@ -10,4 +10,7 @@ export const pathnames = {
}
} satisfies Pathnames;
+// Use the default: `always`
+export const localePrefix = undefined;
+
export type AppPathnames = keyof typeof pathnames;
diff --git a/examples/example-app-router/src/middleware.ts b/examples/example-app-router/src/middleware.ts
index 099f6717f..874a58ed9 100644
--- a/examples/example-app-router/src/middleware.ts
+++ b/examples/example-app-router/src/middleware.ts
@@ -1,10 +1,11 @@
import createMiddleware from 'next-intl/middleware';
-import {pathnames, locales} from './config';
+import {pathnames, locales, localePrefix} from './config';
export default createMiddleware({
defaultLocale: 'en',
locales,
- pathnames
+ pathnames,
+ localePrefix
});
export const config = {
diff --git a/examples/example-app-router/src/navigation.ts b/examples/example-app-router/src/navigation.ts
index d98738ffe..44f437407 100644
--- a/examples/example-app-router/src/navigation.ts
+++ b/examples/example-app-router/src/navigation.ts
@@ -1,8 +1,9 @@
import {createLocalizedPathnamesNavigation} from 'next-intl/navigation';
-import {locales, pathnames} from './config';
+import {locales, pathnames, localePrefix} from './config';
export const {Link, redirect, usePathname, useRouter} =
createLocalizedPathnamesNavigation({
locales,
- pathnames
+ pathnames,
+ localePrefix
});
diff --git a/packages/next-intl/package.json b/packages/next-intl/package.json
index 5b393091c..79c2c1be6 100644
--- a/packages/next-intl/package.json
+++ b/packages/next-intl/package.json
@@ -86,6 +86,7 @@
"@types/negotiator": "^0.6.1",
"@types/node": "^17.0.23",
"@types/react": "18.2.34",
+ "@types/react-dom": "^18.2.17",
"eslint": "^8.54.0",
"eslint-config-molindo": "^7.0.0",
"eslint-plugin-deprecation": "^1.4.1",
@@ -110,11 +111,11 @@
},
{
"path": "dist/production/navigation.react-client.js",
- "limit": "2.6 KB"
+ "limit": "2.62 KB"
},
{
"path": "dist/production/navigation.react-server.js",
- "limit": "2.75 KB"
+ "limit": "2.8 KB"
},
{
"path": "dist/production/server.js",
diff --git a/packages/next-intl/src/middleware/NextIntlMiddlewareConfig.tsx b/packages/next-intl/src/middleware/NextIntlMiddlewareConfig.tsx
index 27bd2581d..72396d3be 100644
--- a/packages/next-intl/src/middleware/NextIntlMiddlewareConfig.tsx
+++ b/packages/next-intl/src/middleware/NextIntlMiddlewareConfig.tsx
@@ -1,6 +1,4 @@
-import {AllLocales, Pathnames} from '../shared/types';
-
-type LocalePrefix = 'as-needed' | 'always' | 'never';
+import {AllLocales, LocalePrefix, Pathnames} from '../shared/types';
type RoutingBaseConfig = {
/** A list of all locales that are supported. */
diff --git a/packages/next-intl/src/middleware/middleware.tsx b/packages/next-intl/src/middleware/middleware.tsx
index ff2ca55dd..3259fb566 100644
--- a/packages/next-intl/src/middleware/middleware.tsx
+++ b/packages/next-intl/src/middleware/middleware.tsx
@@ -178,13 +178,16 @@ export default function createMiddleware(
response = redirect(pathWithSearch);
}
} else {
- const pathWithSearch = getPathWithSearch(
+ const internalPathWithSearch = getPathWithSearch(
pathname,
request.nextUrl.search
);
if (hasLocalePrefix) {
- const basePath = getBasePath(pathWithSearch, pathLocale);
+ const basePath = getBasePath(
+ getPathWithSearch(normalizedPathname, request.nextUrl.search),
+ pathLocale
+ );
if (configWithDefaults.localePrefix === 'never') {
response = redirect(basePath);
@@ -205,10 +208,10 @@ export default function createMiddleware(
if (domain?.domain !== pathDomain?.domain && !hasUnknownHost) {
response = redirect(basePath, pathDomain?.domain);
} else {
- response = rewrite(pathWithSearch);
+ response = rewrite(internalPathWithSearch);
}
} else {
- response = rewrite(pathWithSearch);
+ response = rewrite(internalPathWithSearch);
}
}
} else {
@@ -221,9 +224,9 @@ export default function createMiddleware(
(configWithDefaults.localePrefix === 'as-needed' ||
configWithDefaults.domains))
) {
- response = rewrite(`/${locale}${pathWithSearch}`);
+ response = rewrite(`/${locale}${internalPathWithSearch}`);
} else {
- response = redirect(`/${locale}${pathWithSearch}`);
+ response = redirect(`/${locale}${internalPathWithSearch}`);
}
}
}
diff --git a/packages/next-intl/src/navigation/react-client/BaseLink.tsx b/packages/next-intl/src/navigation/react-client/ClientLink.tsx
similarity index 73%
rename from packages/next-intl/src/navigation/react-client/BaseLink.tsx
rename to packages/next-intl/src/navigation/react-client/ClientLink.tsx
index 96977c479..84f2bc7f9 100644
--- a/packages/next-intl/src/navigation/react-client/BaseLink.tsx
+++ b/packages/next-intl/src/navigation/react-client/ClientLink.tsx
@@ -1,28 +1,23 @@
import React, {ComponentProps, ReactElement, forwardRef} from 'react';
import useLocale from '../../react-client/useLocale';
-import BaseLinkWithLocale from '../../shared/BaseLinkWithLocale';
import {AllLocales} from '../../shared/types';
+import BaseLink from '../shared/BaseLink';
type Props = Omit<
- ComponentProps,
+ ComponentProps,
'locale'
> & {
locale?: Locales[number];
};
-function BaseLink(
+function ClientLink(
{locale, ...rest}: Props,
ref: Props['ref']
) {
const defaultLocale = useLocale();
const linkLocale = locale || defaultLocale;
return (
-
+
);
}
@@ -46,8 +41,10 @@ function BaseLink(
* the `set-cookie` response header would cause the locale cookie on the current
* page to be overwritten before the user even decides to change the locale.
*/
-const BaseLinkWithRef = forwardRef(BaseLink) as (
+const ClientLinkWithRef = forwardRef(ClientLink) as <
+ Locales extends AllLocales
+>(
props: Props & {ref?: Props['ref']}
) => ReactElement;
-(BaseLinkWithRef as any).displayName = 'Link';
-export default BaseLinkWithRef;
+(ClientLinkWithRef as any).displayName = 'ClientLink';
+export default ClientLinkWithRef;
diff --git a/packages/next-intl/src/navigation/react-client/baseRedirect.tsx b/packages/next-intl/src/navigation/react-client/clientRedirect.tsx
similarity index 60%
rename from packages/next-intl/src/navigation/react-client/baseRedirect.tsx
rename to packages/next-intl/src/navigation/react-client/clientRedirect.tsx
index e9fd47032..9340a576d 100644
--- a/packages/next-intl/src/navigation/react-client/baseRedirect.tsx
+++ b/packages/next-intl/src/navigation/react-client/clientRedirect.tsx
@@ -1,10 +1,10 @@
import useLocale from '../../react-client/useLocale';
-import redirectWithLocale from '../../shared/redirectWithLocale';
-import {ParametersExceptFirstTwo} from '../../shared/types';
+import {LocalePrefix, ParametersExceptFirstTwo} from '../../shared/types';
+import baseRedirect from '../shared/baseRedirect';
-export default function baseRedirect(
- pathname: string,
- ...args: ParametersExceptFirstTwo
+export default function clientRedirect(
+ params: {localePrefix?: LocalePrefix; pathname: string},
+ ...args: ParametersExceptFirstTwo
) {
let locale;
try {
@@ -18,5 +18,5 @@ export default function baseRedirect(
);
}
- return redirectWithLocale(pathname, locale, ...args);
+ return baseRedirect({...params, locale}, ...args);
}
diff --git a/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.tsx b/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.tsx
index 9da45f0a8..26f817ecc 100644
--- a/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.tsx
+++ b/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.tsx
@@ -1,6 +1,11 @@
import React, {ComponentProps, ReactElement, forwardRef} from 'react';
import useLocale from '../../react-client/useLocale';
-import {AllLocales, ParametersExceptFirst, Pathnames} from '../../shared/types';
+import {
+ AllLocales,
+ LocalePrefix,
+ ParametersExceptFirst,
+ Pathnames
+} from '../../shared/types';
import {
compileLocalizedPathname,
getRoute,
@@ -8,18 +13,22 @@ import {
HrefOrHrefWithParams,
HrefOrUrlObjectWithParams
} from '../shared/utils';
-import BaseLink from './BaseLink';
-import baseRedirect from './baseRedirect';
+import ClientLink from './ClientLink';
+import clientRedirect from './clientRedirect';
import useBasePathname from './useBasePathname';
import useBaseRouter from './useBaseRouter';
export default function createLocalizedPathnamesNavigation<
Locales extends AllLocales,
PathnamesConfig extends Pathnames
->({locales, pathnames}: {locales: Locales; pathnames: PathnamesConfig}) {
- function useTypedLocale(): (typeof locales)[number] {
+>(opts: {
+ locales: Locales;
+ pathnames: PathnamesConfig;
+ localePrefix?: LocalePrefix;
+}) {
+ function useTypedLocale(): (typeof opts.locales)[number] {
const locale = useLocale();
- const isValid = locales.includes(locale as any);
+ const isValid = opts.locales.includes(locale as any);
if (!isValid) {
throw new Error(
process.env.NODE_ENV !== 'production'
@@ -31,7 +40,7 @@ export default function createLocalizedPathnamesNavigation<
}
type LinkProps = Omit<
- ComponentProps,
+ ComponentProps,
'href' | 'name'
> & {
href: HrefOrUrlObjectWithParams;
@@ -39,13 +48,13 @@ export default function createLocalizedPathnamesNavigation<
};
function Link(
{href, locale, ...rest}: LinkProps,
- ref?: ComponentProps['ref']
+ ref?: ComponentProps['ref']
) {
const defaultLocale = useTypedLocale();
const finalLocale = locale || defaultLocale;
return (
- ({
locale: finalLocale,
@@ -53,9 +62,10 @@ export default function createLocalizedPathnamesNavigation<
pathname: href,
// @ts-expect-error -- This is ok
params: typeof href === 'object' ? href.params : undefined,
- pathnames
+ pathnames: opts.pathnames
})}
locale={locale}
+ localePrefix={opts.localePrefix}
{...rest}
/>
);
@@ -63,18 +73,20 @@ export default function createLocalizedPathnamesNavigation<
const LinkWithRef = forwardRef(Link) as unknown as <
Pathname extends keyof PathnamesConfig
>(
- props: LinkProps & {ref?: ComponentProps['ref']}
+ props: LinkProps & {
+ ref?: ComponentProps['ref'];
+ }
) => ReactElement;
(LinkWithRef as any).displayName = 'Link';
function redirect(
href: HrefOrHrefWithParams,
- ...args: ParametersExceptFirst
+ ...args: ParametersExceptFirst
) {
// eslint-disable-next-line react-hooks/rules-of-hooks -- Reading from context here is fine, since `redirect` should be called during render
const locale = useTypedLocale();
const resolvedHref = getPathname({href, locale});
- return baseRedirect(resolvedHref, ...args);
+ return clientRedirect({...opts, pathname: resolvedHref}, ...args);
}
function useRouter() {
@@ -121,7 +133,7 @@ export default function createLocalizedPathnamesNavigation<
function usePathname(): keyof PathnamesConfig {
const pathname = useBasePathname();
const locale = useTypedLocale();
- return getRoute({pathname, locale, pathnames});
+ return getRoute({pathname, locale, pathnames: opts.pathnames});
}
function getPathname({
@@ -134,7 +146,7 @@ export default function createLocalizedPathnamesNavigation<
return compileLocalizedPathname({
...normalizeNameOrNameWithParams(href),
locale,
- pathnames
+ pathnames: opts.pathnames
});
}
diff --git a/packages/next-intl/src/navigation/react-client/createSharedPathnamesNavigation.tsx b/packages/next-intl/src/navigation/react-client/createSharedPathnamesNavigation.tsx
index b56cd6f09..230dc75e4 100644
--- a/packages/next-intl/src/navigation/react-client/createSharedPathnamesNavigation.tsx
+++ b/packages/next-intl/src/navigation/react-client/createSharedPathnamesNavigation.tsx
@@ -1,16 +1,45 @@
-import {AllLocales} from '../../shared/types';
-import BaseLink from './BaseLink';
-import baseRedirect from './baseRedirect';
+import React, {ComponentProps, ReactElement, forwardRef} from 'react';
+import {
+ AllLocales,
+ LocalePrefix,
+ ParametersExceptFirst
+} from '../../shared/types';
+import ClientLink from './ClientLink';
+import clientRedirect from './clientRedirect';
import useBasePathname from './useBasePathname';
import useBaseRouter from './useBaseRouter';
export default function createSharedPathnamesNavigation<
Locales extends AllLocales
- // eslint-disable-next-line @typescript-eslint/no-unused-vars -- The value is not used yet, only the type information is important
->(opts: {locales: Locales}) {
+>(opts: {locales: Locales; localePrefix?: LocalePrefix}) {
+ type LinkProps = Omit<
+ ComponentProps>,
+ 'localePrefix'
+ >;
+ function Link(props: LinkProps, ref: LinkProps['ref']) {
+ return (
+
+ ref={ref}
+ localePrefix={opts.localePrefix}
+ {...props}
+ />
+ );
+ }
+ const LinkWithRef = forwardRef(Link) as (
+ props: LinkProps & {ref?: LinkProps['ref']}
+ ) => ReactElement;
+ (LinkWithRef as any).displayName = 'Link';
+
+ function redirect(
+ pathname: string,
+ ...args: ParametersExceptFirst
+ ) {
+ return clientRedirect({...opts, pathname}, ...args);
+ }
+
return {
- Link: BaseLink as typeof BaseLink,
- redirect: baseRedirect,
+ Link: LinkWithRef,
+ redirect,
usePathname: useBasePathname,
useRouter: useBaseRouter
};
diff --git a/packages/next-intl/src/navigation/react-server/BaseLink.tsx b/packages/next-intl/src/navigation/react-server/ServerLink.tsx
similarity index 50%
rename from packages/next-intl/src/navigation/react-server/BaseLink.tsx
rename to packages/next-intl/src/navigation/react-server/ServerLink.tsx
index f671f7cdc..384e25b6c 100644
--- a/packages/next-intl/src/navigation/react-server/BaseLink.tsx
+++ b/packages/next-intl/src/navigation/react-server/ServerLink.tsx
@@ -1,20 +1,18 @@
import React, {ComponentProps} from 'react';
import {getLocale} from '../../server';
-import BaseLinkWithLocale from '../../shared/BaseLinkWithLocale';
import {AllLocales} from '../../shared/types';
+import BaseLink from '../shared/BaseLink';
type Props = Omit<
- ComponentProps,
+ ComponentProps,
'locale'
> & {
locale?: Locales[number];
};
-export default async function BaseLink({
+export default async function ServerLink({
locale,
...rest
}: Props) {
- return (
-
- );
+ return ;
}
diff --git a/packages/next-intl/src/navigation/react-server/baseRedirect.tsx b/packages/next-intl/src/navigation/react-server/baseRedirect.tsx
deleted file mode 100644
index 0a1705b64..000000000
--- a/packages/next-intl/src/navigation/react-server/baseRedirect.tsx
+++ /dev/null
@@ -1,11 +0,0 @@
-import {getRequestLocale} from '../../server/RequestLocale';
-import redirectWithLocale from '../../shared/redirectWithLocale';
-import {ParametersExceptFirstTwo} from '../../shared/types';
-
-export default function baseRedirect(
- pathname: string,
- ...args: ParametersExceptFirstTwo
-) {
- const locale = getRequestLocale();
- return redirectWithLocale(pathname, locale, ...args);
-}
diff --git a/packages/next-intl/src/navigation/react-server/createLocalizedPathnamesNavigation.tsx b/packages/next-intl/src/navigation/react-server/createLocalizedPathnamesNavigation.tsx
index 7f1ea7e39..5b233f4da 100644
--- a/packages/next-intl/src/navigation/react-server/createLocalizedPathnamesNavigation.tsx
+++ b/packages/next-intl/src/navigation/react-server/createLocalizedPathnamesNavigation.tsx
@@ -1,21 +1,34 @@
import React, {ComponentProps} from 'react';
import {getRequestLocale} from '../../server/RequestLocale';
-import {AllLocales, ParametersExceptFirst, Pathnames} from '../../shared/types';
+import {
+ AllLocales,
+ LocalePrefix,
+ ParametersExceptFirst,
+ Pathnames
+} from '../../shared/types';
import {
HrefOrHrefWithParams,
HrefOrUrlObjectWithParams,
compileLocalizedPathname,
normalizeNameOrNameWithParams
} from '../shared/utils';
-import BaseLink from './BaseLink';
-import baseRedirect from './baseRedirect';
+import ServerLink from './ServerLink';
+import serverRedirect from './serverRedirect';
export default function createLocalizedPathnamesNavigation<
Locales extends AllLocales,
PathnamesConfig extends Pathnames
->({locales, pathnames}: {locales: Locales; pathnames: Pathnames}) {
+>({
+ localePrefix,
+ locales,
+ pathnames
+}: {
+ locales: Locales;
+ pathnames: Pathnames;
+ localePrefix?: LocalePrefix;
+}) {
type LinkProps = Omit<
- ComponentProps,
+ ComponentProps,
'href' | 'name'
> & {
href: HrefOrUrlObjectWithParams;
@@ -30,7 +43,7 @@ export default function createLocalizedPathnamesNavigation<
const finalLocale = locale || defaultLocale;
return (
- ({
locale: finalLocale,
// @ts-expect-error -- This is ok
@@ -40,6 +53,7 @@ export default function createLocalizedPathnamesNavigation<
pathnames
})}
locale={locale}
+ localePrefix={localePrefix}
{...rest}
/>
);
@@ -47,11 +61,11 @@ export default function createLocalizedPathnamesNavigation<
function redirect(
href: HrefOrHrefWithParams,
- ...args: ParametersExceptFirst
+ ...args: ParametersExceptFirst
) {
const locale = getRequestLocale();
- const resolvedHref = getPathname({href, locale});
- return baseRedirect(resolvedHref, ...args);
+ const pathname = getPathname({href, locale});
+ return serverRedirect({localePrefix, pathname}, ...args);
}
function getPathname({
diff --git a/packages/next-intl/src/navigation/react-server/createSharedPathnamesNavigation.tsx b/packages/next-intl/src/navigation/react-server/createSharedPathnamesNavigation.tsx
index 58458c2cd..46eebbd7b 100644
--- a/packages/next-intl/src/navigation/react-server/createSharedPathnamesNavigation.tsx
+++ b/packages/next-intl/src/navigation/react-server/createSharedPathnamesNavigation.tsx
@@ -1,11 +1,15 @@
-import {AllLocales} from '../../shared/types';
-import BaseLink from './BaseLink';
-import baseRedirect from './baseRedirect';
+import React, {ComponentProps} from 'react';
+import {
+ AllLocales,
+ LocalePrefix,
+ ParametersExceptFirst
+} from '../../shared/types';
+import ServerLink from './ServerLink';
+import serverRedirect from './serverRedirect';
export default function createSharedPathnamesNavigation<
Locales extends AllLocales
- // eslint-disable-next-line @typescript-eslint/no-unused-vars -- The value is not used yet, only the type information is important
->(opts: {locales: Locales}) {
+>(opts: {locales: Locales; localePrefix?: LocalePrefix}) {
function notSupported(message: string) {
return () => {
throw new Error(
@@ -14,9 +18,20 @@ export default function createSharedPathnamesNavigation<
};
}
+ function Link(props: ComponentProps>) {
+ return localePrefix={opts.localePrefix} {...props} />;
+ }
+
+ function redirect(
+ pathname: string,
+ ...args: ParametersExceptFirst
+ ) {
+ return serverRedirect({...opts, pathname}, ...args);
+ }
+
return {
- Link: BaseLink,
- redirect: baseRedirect,
+ Link,
+ redirect,
usePathname: notSupported('usePathname'),
useRouter: notSupported('useRouter')
};
diff --git a/packages/next-intl/src/navigation/react-server/serverRedirect.tsx b/packages/next-intl/src/navigation/react-server/serverRedirect.tsx
new file mode 100644
index 000000000..5badc95be
--- /dev/null
+++ b/packages/next-intl/src/navigation/react-server/serverRedirect.tsx
@@ -0,0 +1,11 @@
+import {getRequestLocale} from '../../server/RequestLocale';
+import {LocalePrefix, ParametersExceptFirstTwo} from '../../shared/types';
+import baseRedirect from '../shared/baseRedirect';
+
+export default function serverRedirect(
+ params: {pathname: string; localePrefix?: LocalePrefix},
+ ...args: ParametersExceptFirstTwo
+) {
+ const locale = getRequestLocale();
+ return baseRedirect({...params, locale}, ...args);
+}
diff --git a/packages/next-intl/src/shared/BaseLinkWithLocale.tsx b/packages/next-intl/src/navigation/shared/BaseLink.tsx
similarity index 50%
rename from packages/next-intl/src/shared/BaseLinkWithLocale.tsx
rename to packages/next-intl/src/navigation/shared/BaseLink.tsx
index df5d01b41..2cbe953bf 100644
--- a/packages/next-intl/src/shared/BaseLinkWithLocale.tsx
+++ b/packages/next-intl/src/navigation/shared/BaseLink.tsx
@@ -3,15 +3,17 @@
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 {isLocalHref, localizeHref, prefixHref} from './utils';
+import useLocale from '../../react-client/useLocale';
+import {LocalePrefix} from '../../shared/types';
+import {isLocalHref, localizeHref, prefixHref} from '../../shared/utils';
type Props = Omit, 'locale'> & {
locale: string;
+ localePrefix?: LocalePrefix;
};
-function BaseLinkWithLocale(
- {href, locale, prefetch, ...rest}: Props,
+function BaseLink(
+ {href, locale, localePrefix, prefetch, ...rest}: Props,
ref: Props['ref']
) {
// The types aren't entirely correct here. Outside of Next.js
@@ -22,26 +24,28 @@ function BaseLinkWithLocale(
const isChangingLocale = locale !== defaultLocale;
const [localizedHref, setLocalizedHref] = useState(() =>
- isLocalHref(href) && locale
- ? // Potentially the href shouldn't be prefixed, but to determine this we
+ isLocalHref(href) && (localePrefix !== '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. During the server side render (both in RSC as well as SSR),
- // we don't have this information. 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.
+ // 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, locale)
: href
);
useEffect(() => {
- if (!pathname) return;
+ if (!pathname || localePrefix === 'never') return;
setLocalizedHref(
localizeHref(href, locale, defaultLocale, pathname ?? undefined)
);
- }, [defaultLocale, href, locale, pathname]);
+ }, [defaultLocale, href, locale, localePrefix, pathname]);
if (isChangingLocale) {
if (prefetch && process.env.NODE_ENV !== 'production') {
@@ -57,4 +61,6 @@ function BaseLinkWithLocale(
);
}
-export default forwardRef(BaseLinkWithLocale);
+const BaseLinkWithRef = forwardRef(BaseLink);
+(BaseLinkWithRef as any).displayName = 'ClientLink';
+export default BaseLinkWithRef;
diff --git a/packages/next-intl/src/navigation/shared/StrictParams.tsx b/packages/next-intl/src/navigation/shared/StrictParams.tsx
index ed4332b02..7606054c0 100644
--- a/packages/next-intl/src/navigation/shared/StrictParams.tsx
+++ b/packages/next-intl/src/navigation/shared/StrictParams.tsx
@@ -11,16 +11,16 @@ type ReadUntil = Path extends `${infer Match}]${infer Rest}`
type RemovePrefixes = Key extends `[...${infer Name}`
? Name
: Key extends `...${infer Name}`
- ? Name
- : Key;
+ ? Name
+ : Key;
type StrictParams = Pathname extends `${string}[${string}`
? {
[Key in ReadFrom[number] as RemovePrefixes]: Key extends `[...${string}`
? Array | undefined
: Key extends `...${string}`
- ? Array
- : ParamValue;
+ ? Array
+ : ParamValue;
}
: never;
diff --git a/packages/next-intl/src/navigation/shared/baseRedirect.tsx b/packages/next-intl/src/navigation/shared/baseRedirect.tsx
new file mode 100644
index 000000000..6127dc29e
--- /dev/null
+++ b/packages/next-intl/src/navigation/shared/baseRedirect.tsx
@@ -0,0 +1,22 @@
+import {redirect as nextRedirect} from 'next/navigation';
+import {
+ AllLocales,
+ LocalePrefix,
+ ParametersExceptFirst
+} from '../../shared/types';
+import {prefixPathname} from '../../shared/utils';
+
+export default function baseRedirect(
+ params: {
+ pathname: string;
+ locale: AllLocales[number];
+ localePrefix?: LocalePrefix;
+ },
+ ...args: ParametersExceptFirst
+) {
+ const localizedPathname =
+ params.localePrefix === 'never'
+ ? params.pathname
+ : prefixPathname(params.locale, params.pathname);
+ return nextRedirect(localizedPathname, ...args);
+}
diff --git a/packages/next-intl/src/navigation/shared/utils.tsx b/packages/next-intl/src/navigation/shared/utils.tsx
index c4acf6999..2c8bf21eb 100644
--- a/packages/next-intl/src/navigation/shared/utils.tsx
+++ b/packages/next-intl/src/navigation/shared/utils.tsx
@@ -13,10 +13,10 @@ type HrefOrHrefWithParamsImpl =
? // Optional catch-all
Pathname | ({pathname: Pathname; params?: StrictParams} & Other)
: Pathname extends `${string}[${string}`
- ? // Required catch-all & regular params
- {pathname: Pathname; params: StrictParams} & Other
- : // No params
- Pathname | ({pathname: Pathname} & Other);
+ ? // Required catch-all & regular params
+ {pathname: Pathname; params: StrictParams} & Other
+ : // No params
+ Pathname | ({pathname: Pathname} & Other);
export type HrefOrUrlObjectWithParams = HrefOrHrefWithParamsImpl<
Pathname,
diff --git a/packages/next-intl/src/shared/redirectWithLocale.tsx b/packages/next-intl/src/shared/redirectWithLocale.tsx
deleted file mode 100644
index f14eb9e81..000000000
--- a/packages/next-intl/src/shared/redirectWithLocale.tsx
+++ /dev/null
@@ -1,12 +0,0 @@
-import {redirect as nextRedirect} from 'next/navigation';
-import {AllLocales, ParametersExceptFirst} from './types';
-import {localizePathname} from './utils';
-
-export default function redirectWithLocale(
- pathname: string,
- locale: AllLocales[number],
- ...args: ParametersExceptFirst
-) {
- const localizedPathname = localizePathname(locale, pathname);
- return nextRedirect(localizedPathname, ...args);
-}
diff --git a/packages/next-intl/src/shared/types.tsx b/packages/next-intl/src/shared/types.tsx
index 0a628c03a..590d4d1fb 100644
--- a/packages/next-intl/src/shared/types.tsx
+++ b/packages/next-intl/src/shared/types.tsx
@@ -1,5 +1,7 @@
export type AllLocales = ReadonlyArray;
+export type LocalePrefix = 'as-needed' | 'always' | 'never';
+
export type Pathnames = Record<
string,
{[Key in Locales[number]]: string} | string
diff --git a/packages/next-intl/src/shared/utils.tsx b/packages/next-intl/src/shared/utils.tsx
index 36ae5ab69..cacc52f88 100644
--- a/packages/next-intl/src/shared/utils.tsx
+++ b/packages/next-intl/src/shared/utils.tsx
@@ -21,29 +21,29 @@ export function isLocalHref(href: Href) {
export function localizeHref(
href: string,
locale: string,
- defaultLocale: string,
+ curLocale: string,
pathname: string
): string;
export function localizeHref(
href: UrlObject | string,
locale: string,
- defaultLocale: string,
+ curLocale: string,
pathname: string
): UrlObject | string;
export function localizeHref(
href: UrlObject | string,
locale: string,
- defaultLocale: string = locale,
+ curLocale: string = locale,
pathname: string
) {
if (!isLocalHref(href) || isRelativeHref(href)) {
return href;
}
- const isSwitchingLocale = locale !== defaultLocale;
+ const isSwitchingLocale = locale !== curLocale;
const isPathnamePrefixed =
locale == null || hasPathnamePrefixed(locale, pathname);
- const shouldPrefix = isPathnamePrefixed || isSwitchingLocale;
+ const shouldPrefix = isSwitchingLocale || isPathnamePrefixed;
if (shouldPrefix && locale != null) {
return prefixHref(href, locale);
@@ -60,11 +60,11 @@ export function prefixHref(
export function prefixHref(href: UrlObject | string, locale: string) {
let prefixedHref;
if (typeof href === 'string') {
- prefixedHref = localizePathname(locale, href);
+ prefixedHref = prefixPathname(locale, href);
} else {
prefixedHref = {...href};
if (href.pathname) {
- prefixedHref.pathname = localizePathname(locale, href.pathname);
+ prefixedHref.pathname = prefixPathname(locale, href.pathname);
}
}
@@ -75,7 +75,7 @@ export function unlocalizePathname(pathname: string, locale: string) {
return pathname.replace(new RegExp(`^/${locale}`), '') || '/';
}
-export function localizePathname(locale: string, pathname: string) {
+export function prefixPathname(locale: string, pathname: string) {
let localizedHref = '/' + locale;
// Avoid trailing slashes
diff --git a/packages/next-intl/test/middleware/middleware.test.tsx b/packages/next-intl/test/middleware/middleware.test.tsx
index cb41d2877..0afc09db7 100644
--- a/packages/next-intl/test/middleware/middleware.test.tsx
+++ b/packages/next-intl/test/middleware/middleware.test.tsx
@@ -564,6 +564,37 @@ describe('prefix-based routing', () => {
);
});
});
+
+ describe('localized pathnames with different pathnames for internal and external pathnames for the default locale', () => {
+ const middlewareWithPathnames = createIntlMiddleware({
+ defaultLocale: 'en',
+ locales: ['en', 'de'],
+ localePrefix: 'as-needed',
+ pathnames: {
+ '/internal': '/external'
+ } satisfies Pathnames>
+ });
+
+ it('redirects a request for a localized route to remove the locale prefix while keeping search params at the root', () => {
+ middlewareWithPathnames(createMockRequest('/en?hello', 'en'));
+ expect(MockedNextResponse.next).not.toHaveBeenCalled();
+ expect(MockedNextResponse.rewrite).not.toHaveBeenCalled();
+ expect(MockedNextResponse.redirect).toHaveBeenCalledTimes(1);
+ expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe(
+ 'http://localhost:3000/?hello'
+ );
+ });
+
+ it('redirects a request for a localized route to remove the locale prefix while keeping search params', () => {
+ middlewareWithPathnames(createMockRequest('/en/external?hello', 'en'));
+ expect(MockedNextResponse.next).not.toHaveBeenCalled();
+ expect(MockedNextResponse.rewrite).not.toHaveBeenCalled();
+ expect(MockedNextResponse.redirect).toHaveBeenCalledTimes(1);
+ expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe(
+ 'http://localhost:3000/external?hello'
+ );
+ });
+ });
});
describe('localePrefix: as-needed, localeDetection: false', () => {
@@ -1124,6 +1155,16 @@ describe('prefix-based routing', () => {
'http://localhost:3000/en/users/12'
);
});
+
+ it('redirects a request for a localized route to remove the locale prefix while keeping search params', () => {
+ middlewareWithPathnames(createMockRequest('/de/ueber?hello', 'de'));
+ expect(MockedNextResponse.next).not.toHaveBeenCalled();
+ expect(MockedNextResponse.rewrite).not.toHaveBeenCalled();
+ expect(MockedNextResponse.redirect).toHaveBeenCalledTimes(1);
+ expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe(
+ 'http://localhost:3000/ueber?hello'
+ );
+ });
});
});
});
diff --git a/packages/next-intl/test/navigation/createLocalizedPathnamesNavigation.test.tsx b/packages/next-intl/test/navigation/createLocalizedPathnamesNavigation.test.tsx
new file mode 100644
index 000000000..a91b319d8
--- /dev/null
+++ b/packages/next-intl/test/navigation/createLocalizedPathnamesNavigation.test.tsx
@@ -0,0 +1,435 @@
+import {render, screen} from '@testing-library/react';
+import {
+ usePathname as useNextPathname,
+ useParams,
+ redirect as nextRedirect
+} from 'next/navigation';
+import React from 'react';
+import {renderToString} from 'react-dom/server';
+import {it, describe, vi, expect, beforeEach} from 'vitest';
+import createLocalizedPathnamesNavigationClient from '../../src/navigation/react-client/createLocalizedPathnamesNavigation';
+import createLocalizedPathnamesNavigationServer from '../../src/navigation/react-server/createLocalizedPathnamesNavigation';
+import BaseLink from '../../src/navigation/shared/BaseLink';
+import {Pathnames} from '../../src/navigation.react-client';
+import {getRequestLocale} from '../../src/server/RequestLocale';
+
+vi.mock('next/navigation');
+vi.mock('next-intl/config', () => ({
+ default: async () =>
+ ((await vi.importActual('../../src/server')) as any).getRequestConfig({
+ locale: 'en'
+ })
+}));
+vi.mock('react', async (importOriginal) => ({
+ ...((await importOriginal()) as typeof import('react')),
+ cache(fn: (...args: Array) => unknown) {
+ return (...args: Array) => fn(...args);
+ }
+}));
+// Avoids handling an async component (not supported by renderToString)
+vi.mock('../../src/navigation/react-server/ServerLink', () => ({
+ default({locale, ...rest}: any) {
+ return ;
+ }
+}));
+vi.mock('../../src/server/RequestLocale', () => ({
+ getRequestLocale: vi.fn(() => 'en')
+}));
+
+beforeEach(() => {
+ // usePathname from Next.js returns the pathname the user sees
+ // (i.e. the external one that might be localized)
+ vi.mocked(useNextPathname).mockImplementation(() => '/');
+
+ vi.mocked(getRequestLocale).mockImplementation(() => 'en');
+ vi.mocked(useParams).mockImplementation(() => ({locale: 'en'}));
+});
+
+const locales = ['en', 'de'] as const;
+const pathnames = {
+ '/': '/',
+ '/about': {
+ en: '/about',
+ de: '/ueber-uns'
+ },
+ '/news/[articleSlug]-[articleId]': {
+ en: '/news/[articleSlug]-[articleId]',
+ de: '/neuigkeiten/[articleSlug]-[articleId]'
+ },
+ '/categories/[...parts]': {
+ en: '/categories/[...parts]',
+ de: '/kategorien/[...parts]'
+ },
+ '/catch-all/[[...parts]]': '/catch-all/[[...parts]]'
+} satisfies Pathnames;
+
+describe.each([
+ {
+ env: 'react-client',
+ implementation: createLocalizedPathnamesNavigationClient
+ },
+ {
+ env: 'react-server',
+ implementation: createLocalizedPathnamesNavigationServer
+ }
+])(
+ 'createLocalizedPathnamesNavigation ($env)',
+ ({implementation: createLocalizedPathnamesNavigation}) => {
+ describe("localePrefix: 'always'", () => {
+ const {Link} = createLocalizedPathnamesNavigation({
+ pathnames,
+ locales,
+ localePrefix: 'always'
+ });
+ describe('Link', () => {
+ it('renders a prefix for the default locale', () => {
+ const markup = renderToString(About);
+ expect(markup).toContain('href="/en/about"');
+ });
+
+ 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('/about?foo=bar');
+ });
+ });
+ });
+
+ describe("localePrefix: 'as-needed'", () => {
+ const {Link, getPathname, redirect} = createLocalizedPathnamesNavigation({
+ locales,
+ pathnames,
+ localePrefix: 'as-needed'
+ });
+
+ describe('Link', () => {
+ it('renders a prefix for the default locale initially', () => {
+ const markup = renderToString(About);
+ expect(markup).toContain('href="/en/about"');
+ });
+
+ it("doesn't render a prefix for the default locale eventually", () => {
+ render(Über uns);
+ expect(screen.getByText('Über uns').getAttribute('href')).toBe(
+ '/about'
+ );
+ });
+
+ it('adds a prefix when linking to a non-default locale', () => {
+ render(
+
+ Über uns
+
+ );
+ expect(
+ screen.getByRole('link', {name: 'Über uns'}).getAttribute('href')
+ ).toBe('/de/ueber-uns');
+ });
+
+ it('handles params', () => {
+ render(
+
+ About
+
+ );
+ expect(
+ screen.getByRole('link', {name: 'About'}).getAttribute('href')
+ ).toBe('/de/neuigkeiten/launch-party-3');
+ });
+
+ it('handles catch-all segments', () => {
+ render(
+
+ Test
+
+ );
+ expect(
+ screen.getByRole('link', {name: 'Test'}).getAttribute('href')
+ ).toBe('/categories/clothing/t-shirts');
+ });
+
+ it('handles optional catch-all segments', () => {
+ render(
+
+ Test
+
+ );
+ expect(
+ screen.getByRole('link', {name: 'Test'}).getAttribute('href')
+ ).toBe('/catch-all/one/two');
+ });
+
+ it('supports optional search params', () => {
+ render(
+
+ Test
+
+ );
+ expect(
+ screen.getByRole('link', {name: 'Test'}).getAttribute('href')
+ ).toBe('/about?foo=bar&bar=1&bar=2');
+ });
+
+ it('handles unknown routes', () => {
+ // @ts-expect-error -- Unknown route
+ const {rerender} = render(Unknown);
+ expect(
+ screen.getByRole('link', {name: 'Unknown'}).getAttribute('href')
+ ).toBe('/unknown');
+
+ rerender(
+ // @ts-expect-error -- Unknown route
+
+ Unknown
+
+ );
+ expect(
+ screen.getByRole('link', {name: 'Unknown'}).getAttribute('href')
+ ).toBe('/de/unknown');
+ });
+ });
+
+ describe('redirect', () => {
+ function Component({
+ href
+ }: {
+ href: Parameters>[0];
+ }) {
+ redirect(href);
+ return null;
+ }
+
+ it('can redirect for the default locale', () => {
+ vi.mocked(useNextPathname).mockImplementation(() => '/');
+ const {rerender} = render();
+ expect(nextRedirect).toHaveBeenLastCalledWith('/en');
+
+ rerender();
+ expect(nextRedirect).toHaveBeenLastCalledWith('/en/about');
+
+ rerender(
+
+ );
+ expect(nextRedirect).toHaveBeenLastCalledWith(
+ '/en/news/launch-party-3'
+ );
+ });
+
+ it('can redirect for a non-default locale', () => {
+ vi.mocked(useParams).mockImplementation(() => ({locale: 'de'}));
+ vi.mocked(getRequestLocale).mockImplementation(() => 'de');
+ vi.mocked(useNextPathname).mockImplementation(() => '/');
+
+ const {rerender} = render();
+ expect(nextRedirect).toHaveBeenLastCalledWith('/de');
+
+ rerender();
+ expect(nextRedirect).toHaveBeenLastCalledWith('/de/ueber-uns');
+
+ rerender(
+
+ );
+ expect(nextRedirect).toHaveBeenLastCalledWith(
+ '/de/neuigkeiten/launch-party-3'
+ );
+ });
+
+ it('supports optional search params', () => {
+ vi.mocked(useNextPathname).mockImplementation(() => '/');
+ render(
+
+ );
+ expect(nextRedirect).toHaveBeenLastCalledWith(
+ '/en?foo=bar&bar=1&bar=2'
+ );
+ });
+
+ it('handles unknown routes', () => {
+ vi.mocked(useNextPathname).mockImplementation(() => '/');
+ // @ts-expect-error -- Unknown route
+ render();
+ expect(nextRedirect).toHaveBeenLastCalledWith('/en/unknown');
+ });
+ });
+
+ describe('getPathname', () => {
+ it('resolves to the correct path', () => {
+ expect(
+ getPathname({
+ locale: 'en',
+ href: {
+ pathname: '/categories/[...parts]',
+ params: {parts: ['clothing', 't-shirts']},
+ query: {sort: 'price'}
+ }
+ })
+ ).toBe('/categories/clothing/t-shirts?sort=price');
+ });
+ });
+ });
+
+ describe("localePrefix: 'never'", () => {
+ const {Link, redirect} = createLocalizedPathnamesNavigation({
+ pathnames,
+ locales,
+ localePrefix: 'never'
+ });
+
+ describe('Link', () => {
+ it("doesn't render a prefix for the default locale", () => {
+ const markup = renderToString(About);
+ expect(markup).toContain('href="/about"');
+ });
+
+ it('renders a prefix for a different locale', () => {
+ const markup = renderToString(
+
+ Über uns
+
+ );
+ expect(markup).toContain('href="/de/ueber-uns"');
+ });
+ });
+
+ describe('redirect', () => {
+ function Component({
+ href
+ }: {
+ href: Parameters>[0];
+ }) {
+ redirect(href);
+ return null;
+ }
+
+ it('can redirect for the default locale', () => {
+ vi.mocked(useNextPathname).mockImplementation(() => '/');
+ const {rerender} = render();
+ expect(nextRedirect).toHaveBeenLastCalledWith('/');
+
+ rerender();
+ expect(nextRedirect).toHaveBeenLastCalledWith('/about');
+
+ rerender(
+
+ );
+ expect(nextRedirect).toHaveBeenLastCalledWith('/news/launch-party-3');
+ });
+
+ it('can redirect for a non-default locale', () => {
+ vi.mocked(useParams).mockImplementation(() => ({locale: 'de'}));
+ vi.mocked(getRequestLocale).mockImplementation(() => 'de');
+ vi.mocked(useNextPathname).mockImplementation(() => '/');
+
+ const {rerender} = render();
+ expect(nextRedirect).toHaveBeenLastCalledWith('/');
+
+ rerender();
+ expect(nextRedirect).toHaveBeenLastCalledWith('/ueber-uns');
+
+ rerender(
+
+ );
+ expect(nextRedirect).toHaveBeenLastCalledWith(
+ '/neuigkeiten/launch-party-3'
+ );
+ });
+
+ it('supports optional search params', () => {
+ vi.mocked(useNextPathname).mockImplementation(() => '/');
+ render(
+
+ );
+ expect(nextRedirect).toHaveBeenLastCalledWith(
+ '/?foo=bar&bar=1&bar=2'
+ );
+ });
+
+ it('handles unknown routes', () => {
+ vi.mocked(useNextPathname).mockImplementation(() => '/');
+ // @ts-expect-error -- Unknown route
+ render();
+ expect(nextRedirect).toHaveBeenLastCalledWith('/unknown');
+ });
+ });
+ });
+ }
+);
diff --git a/packages/next-intl/test/navigation/createSharedPathnamesNavigation.test.tsx b/packages/next-intl/test/navigation/createSharedPathnamesNavigation.test.tsx
new file mode 100644
index 000000000..45b5953de
--- /dev/null
+++ b/packages/next-intl/test/navigation/createSharedPathnamesNavigation.test.tsx
@@ -0,0 +1,230 @@
+import {render, screen} from '@testing-library/react';
+import {
+ usePathname as useNextPathname,
+ useParams,
+ redirect as nextRedirect
+} from 'next/navigation';
+import React from 'react';
+import {renderToString} from 'react-dom/server';
+import {it, describe, vi, expect, beforeEach} from 'vitest';
+import createSharedPathnamesNavigationClient from '../../src/navigation/react-client/createSharedPathnamesNavigation';
+import createSharedPathnamesNavigationServer from '../../src/navigation/react-server/createSharedPathnamesNavigation';
+import BaseLink from '../../src/navigation/shared/BaseLink';
+import {getRequestLocale} from '../../src/server/RequestLocale';
+
+vi.mock('next/navigation', () => ({
+ useParams: vi.fn(() => ({locale: 'en'})),
+ usePathname: vi.fn(() => '/'),
+ redirect: vi.fn()
+}));
+vi.mock('next-intl/config', () => ({
+ default: async () =>
+ ((await vi.importActual('../../src/server')) as any).getRequestConfig({
+ locale: 'en'
+ })
+}));
+vi.mock('react', async (importOriginal) => ({
+ ...((await importOriginal()) as typeof import('react')),
+ cache(fn: (...args: Array) => unknown) {
+ return (...args: Array) => fn(...args);
+ }
+}));
+// Avoids handling an async component (not supported by renderToString)
+vi.mock('../../src/navigation/react-server/ServerLink', () => ({
+ default({locale, ...rest}: any) {
+ return ;
+ }
+}));
+vi.mock('../../src/server/RequestLocale', () => ({
+ getRequestLocale: vi.fn(() => 'en')
+}));
+
+beforeEach(() => {
+ vi.mocked(getRequestLocale).mockImplementation(() => 'en');
+ vi.mocked(useParams).mockImplementation(() => ({locale: 'en'}));
+});
+
+const locales = ['en', 'de'] as const;
+
+describe.each([
+ {env: 'react-client', implementation: createSharedPathnamesNavigationClient},
+ {env: 'react-server', implementation: createSharedPathnamesNavigationServer}
+])(
+ 'createSharedPathnamesNavigation ($env)',
+ ({implementation: createSharedPathnamesNavigation}) => {
+ describe("localePrefix: 'always'", () => {
+ const {Link} = createSharedPathnamesNavigation({
+ locales,
+ localePrefix: 'always'
+ });
+ describe('Link', () => {
+ it('renders a prefix for the default locale', () => {
+ const markup = renderToString(About);
+ expect(markup).toContain('href="/en/about"');
+ });
+
+ it('renders a prefix for a different locale', () => {
+ const markup = renderToString(
+
+ Über uns
+
+ );
+ expect(markup).toContain('href="/de/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');
+ });
+ });
+ });
+
+ describe("localePrefix: 'as-needed'", () => {
+ const {Link, redirect} = createSharedPathnamesNavigation({
+ locales,
+ localePrefix: 'as-needed'
+ });
+
+ describe('Link', () => {
+ it('renders a prefix for the default locale initially', () => {
+ const markup = renderToString(About);
+ expect(markup).toContain('href="/en/about"');
+ });
+
+ it("doesn't render a prefix for the default locale eventually", () => {
+ render(Über uns);
+ expect(screen.getByText('Über uns').getAttribute('href')).toBe(
+ '/about'
+ );
+ });
+
+ it('renders a prefix for a different locale', () => {
+ const markup = renderToString(
+
+ Über uns
+
+ );
+ expect(markup).toContain('href="/de/about"');
+ });
+ });
+
+ describe('redirect', () => {
+ function Component({href}: {href: string}) {
+ redirect(href);
+ return null;
+ }
+
+ it('can redirect for the default locale', () => {
+ vi.mocked(useNextPathname).mockImplementation(() => '/');
+ const {rerender} = render();
+ expect(nextRedirect).toHaveBeenLastCalledWith('/en');
+
+ rerender();
+ expect(nextRedirect).toHaveBeenLastCalledWith('/en/about');
+
+ rerender();
+ expect(nextRedirect).toHaveBeenLastCalledWith(
+ '/en/news/launch-party-3'
+ );
+ });
+
+ it('can redirect for a non-default locale', () => {
+ vi.mocked(useParams).mockImplementation(() => ({locale: 'de'}));
+ vi.mocked(getRequestLocale).mockImplementation(() => 'de');
+
+ vi.mocked(useNextPathname).mockImplementation(() => '/');
+ const {rerender} = render();
+ expect(nextRedirect).toHaveBeenLastCalledWith('/de');
+
+ rerender();
+ expect(nextRedirect).toHaveBeenLastCalledWith('/de/about');
+
+ rerender();
+ expect(nextRedirect).toHaveBeenLastCalledWith(
+ '/de/news/launch-party-3'
+ );
+ });
+
+ it('supports optional search params', () => {
+ vi.mocked(useNextPathname).mockImplementation(() => '/');
+ render();
+ expect(nextRedirect).toHaveBeenLastCalledWith(
+ '/en?foo=bar&bar=1&bar=2'
+ );
+ });
+ });
+ });
+
+ describe("localePrefix: 'never'", () => {
+ const {Link, redirect} = createSharedPathnamesNavigation({
+ locales,
+ localePrefix: 'never'
+ });
+
+ describe('Link', () => {
+ it("doesn't render a prefix for the default locale", () => {
+ const markup = renderToString(About);
+ expect(markup).toContain('href="/about"');
+ });
+
+ it('renders a prefix for a different locale', () => {
+ const markup = renderToString(
+
+ Über uns
+
+ );
+ expect(markup).toContain('href="/de/about"');
+ });
+ });
+
+ describe('redirect', () => {
+ function Component({href}: {href: string}) {
+ redirect(href);
+ return null;
+ }
+
+ it('can redirect for the default locale', () => {
+ vi.mocked(useNextPathname).mockImplementation(() => '/');
+ const {rerender} = render();
+ expect(nextRedirect).toHaveBeenLastCalledWith('/');
+
+ rerender();
+ expect(nextRedirect).toHaveBeenLastCalledWith('/about');
+
+ rerender();
+ expect(nextRedirect).toHaveBeenLastCalledWith('/news/launch-party-3');
+ });
+
+ it('can redirect for a non-default locale', () => {
+ vi.mocked(useParams).mockImplementation(() => ({locale: 'de'}));
+ vi.mocked(getRequestLocale).mockImplementation(() => 'de');
+
+ vi.mocked(useNextPathname).mockImplementation(() => '/');
+ const {rerender} = render();
+ expect(nextRedirect).toHaveBeenLastCalledWith('/');
+
+ rerender();
+ expect(nextRedirect).toHaveBeenLastCalledWith('/about');
+
+ rerender();
+ expect(nextRedirect).toHaveBeenLastCalledWith('/news/launch-party-3');
+ });
+ });
+ });
+ }
+);
diff --git a/packages/next-intl/test/navigation/react-client/BaseLink.test.tsx b/packages/next-intl/test/navigation/react-client/ClientLink.test.tsx
similarity index 79%
rename from packages/next-intl/test/navigation/react-client/BaseLink.test.tsx
rename to packages/next-intl/test/navigation/react-client/ClientLink.test.tsx
index 450591fff..2d2a6f6ab 100644
--- a/packages/next-intl/test/navigation/react-client/BaseLink.test.tsx
+++ b/packages/next-intl/test/navigation/react-client/ClientLink.test.tsx
@@ -3,7 +3,7 @@ import {usePathname, useParams} from 'next/navigation';
import React from 'react';
import {it, describe, vi, beforeEach, expect} from 'vitest';
import {NextIntlClientProvider} from '../../../src/index.react-client';
-import BaseLink from '../../../src/navigation/react-client/BaseLink';
+import ClientLink from '../../../src/navigation/react-client/ClientLink';
vi.mock('next/navigation');
@@ -14,7 +14,7 @@ describe('unprefixed routing', () => {
});
it('renders an href without a locale if the locale matches', () => {
- render(Test);
+ render(Test);
expect(screen.getByRole('link', {name: 'Test'}).getAttribute('href')).toBe(
'/test'
);
@@ -22,7 +22,9 @@ describe('unprefixed routing', () => {
it('renders an href without a locale if the locale matches for an object href', () => {
render(
- Test
+
+ Test
+
);
expect(screen.getByRole('link', {name: 'Test'}).getAttribute('href')).toBe(
'/test?foo=bar'
@@ -31,9 +33,9 @@ describe('unprefixed routing', () => {
it('renders an href with a locale if the locale changes', () => {
render(
-
+
Test
-
+
);
expect(screen.getByRole('link', {name: 'Test'}).getAttribute('href')).toBe(
'/de/test'
@@ -42,9 +44,9 @@ describe('unprefixed routing', () => {
it('renders an href with a locale if the locale changes for an object href', () => {
render(
-
+
Test
-
+
);
expect(screen.getByRole('link', {name: 'Test'}).getAttribute('href')).toBe(
'/de/test'
@@ -52,7 +54,7 @@ describe('unprefixed routing', () => {
});
it('works for external urls', () => {
- render(Test);
+ render(Test);
expect(screen.getByRole('link', {name: 'Test'}).getAttribute('href')).toBe(
'https://example.com'
);
@@ -60,7 +62,7 @@ describe('unprefixed routing', () => {
it('works for external urls with an object href', () => {
render(
- {
}}
>
Test
-
+
);
expect(screen.getByRole('link', {name: 'Test'}).getAttribute('href')).toBe(
'https://example.com/test'
@@ -79,21 +81,21 @@ describe('unprefixed routing', () => {
let ref;
render(
- {
ref = node;
}}
href="/test"
>
Test
-
+
);
expect(ref).toBeDefined();
});
it('sets an hreflang', () => {
- render(Test);
+ render(Test);
expect(
screen.getByRole('link', {name: 'Test'}).getAttribute('hreflang')
).toBe('en');
@@ -107,14 +109,14 @@ describe('prefixed routing', () => {
});
it('renders an href with a locale if the locale matches', () => {
- render(Test);
+ render(Test);
expect(screen.getByRole('link', {name: 'Test'}).getAttribute('href')).toBe(
'/en/test'
);
});
it('renders an href without a locale if the locale matches for an object href', () => {
- render(Test);
+ render(Test);
expect(screen.getByRole('link', {name: 'Test'}).getAttribute('href')).toBe(
'/en/test'
);
@@ -122,9 +124,9 @@ describe('prefixed routing', () => {
it('renders an href with a locale if the locale changes', () => {
render(
-
+
Test
-
+
);
expect(screen.getByRole('link', {name: 'Test'}).getAttribute('href')).toBe(
'/de/test'
@@ -133,9 +135,9 @@ describe('prefixed routing', () => {
it('renders an href with a locale if the locale changes for an object href', () => {
render(
-
+
Test
-
+
);
expect(screen.getByRole('link', {name: 'Test'}).getAttribute('href')).toBe(
'/de/test'
@@ -143,7 +145,7 @@ describe('prefixed routing', () => {
});
it('works for external urls', () => {
- render(Test);
+ render(Test);
expect(screen.getByRole('link', {name: 'Test'}).getAttribute('href')).toBe(
'https://example.com'
);
@@ -151,7 +153,7 @@ describe('prefixed routing', () => {
it('works for external urls with an object href', () => {
render(
- {
}}
>
Test
-
+
);
expect(screen.getByRole('link', {name: 'Test'}).getAttribute('href')).toBe(
'https://example.com/test'
@@ -175,7 +177,7 @@ describe('usage outside of Next.js', () => {
it('works with a provider', () => {
render(
- Test
+ Test
);
expect(screen.getByRole('link', {name: 'Test'}).getAttribute('href')).toBe(
@@ -184,7 +186,7 @@ describe('usage outside of Next.js', () => {
});
it('throws without a provider', () => {
- expect(() => render(Test)).toThrow(
+ expect(() => render(Test)).toThrow(
'No intl context found. Have you configured the provider?'
);
});
diff --git a/packages/next-intl/test/navigation/react-client/createLocalizedPathnamesNavigation.test.tsx b/packages/next-intl/test/navigation/react-client/createLocalizedPathnamesNavigation.test.tsx
index bd57d327e..8b727bfb3 100644
--- a/packages/next-intl/test/navigation/react-client/createLocalizedPathnamesNavigation.test.tsx
+++ b/packages/next-intl/test/navigation/react-client/createLocalizedPathnamesNavigation.test.tsx
@@ -2,8 +2,7 @@ import {render, screen} from '@testing-library/react';
import {
usePathname as useNextPathname,
useParams,
- useRouter as useNextRouter,
- redirect as nextRedirect
+ useRouter as useNextRouter
} from 'next/navigation';
import React, {ComponentProps} from 'react';
import {it, describe, vi, beforeEach, expect, Mock} from 'vitest';
@@ -32,12 +31,6 @@ const pathnames = {
'/catch-all/[[...parts]]': '/catch-all/[[...parts]]'
} satisfies Pathnames;
-const {Link, getPathname, redirect, usePathname, useRouter} =
- createLocalizedPathnamesNavigation({
- locales,
- pathnames
- });
-
beforeEach(() => {
const router = {
push: vi.fn(),
@@ -56,483 +49,318 @@ beforeEach(() => {
vi.mocked(useParams).mockImplementation(() => ({locale: 'en'}));
});
-describe('redirect', () => {
- it('can redirect for the default locale', () => {
- function Component({
- href
- }: {
- href: Parameters>[0];
- }) {
- redirect(href);
- return null;
- }
-
- vi.mocked(useNextPathname).mockImplementation(() => '/');
- const {rerender} = render();
- expect(nextRedirect).toHaveBeenLastCalledWith('/en');
-
- rerender();
- expect(nextRedirect).toHaveBeenLastCalledWith('/en/about');
-
- rerender(
-
- );
- expect(nextRedirect).toHaveBeenLastCalledWith('/en/news/launch-party-3');
- });
-
- it('can redirect for a non-default locale', () => {
- vi.mocked(useParams).mockImplementation(() => ({locale: 'de'}));
- function Component({
- href
- }: {
- href: Parameters>[0];
- }) {
- redirect(href);
- return null;
- }
- vi.mocked(useNextPathname).mockImplementation(() => '/');
- const {rerender} = render();
- expect(nextRedirect).toHaveBeenLastCalledWith('/de');
-
- rerender();
- expect(nextRedirect).toHaveBeenLastCalledWith('/de/ueber-uns');
-
- rerender(
-
- );
- expect(nextRedirect).toHaveBeenLastCalledWith(
- '/de/neuigkeiten/launch-party-3'
- );
- });
-
- it('supports optional search params', () => {
- function Component({
- href
- }: {
- href: Parameters>[0];
- }) {
- redirect(href);
- return null;
- }
-
- vi.mocked(useNextPathname).mockImplementation(() => '/');
- render(
-
- );
- expect(nextRedirect).toHaveBeenLastCalledWith('/en?foo=bar&bar=1&bar=2');
- });
-
- it('handles unknown route', () => {
- function Component({
- href
- }: {
- href: Parameters>[0];
- }) {
- redirect(href);
- return null;
- }
-
- vi.mocked(useNextPathname).mockImplementation(() => '/');
- // @ts-expect-error -- Unknown route
- render();
- expect(nextRedirect).toHaveBeenLastCalledWith('/en/unknown');
- });
-});
-
-describe('usePathname', () => {
- it('returns the internal pathname for the default locale', () => {
- function Component() {
- const pathname = usePathname();
- return <>{pathname}>;
- }
- vi.mocked(useNextPathname).mockImplementation(() => '/');
- const {rerender} = render();
- screen.getByText('/');
-
- vi.mocked(useNextPathname).mockImplementation(() => '/about');
- rerender();
- screen.getByText('/about');
-
- vi.mocked(useNextPathname).mockImplementation(() => '/news/launch-party-3');
- rerender();
- screen.getByText('/news/[articleSlug]-[articleId]');
- });
-
- it('returns the internal pathname a non-default locale', () => {
- vi.mocked(useParams).mockImplementation(() => ({locale: 'de'}));
-
- function Component() {
- const pathname = usePathname();
- return <>{pathname}>;
- }
- vi.mocked(useNextPathname).mockImplementation(() => '/de');
- const {rerender} = render();
- screen.getByText('/');
-
- vi.mocked(useNextPathname).mockImplementation(() => '/de/ueber-uns');
- rerender();
- screen.getByText('/about');
-
- vi.mocked(useNextPathname).mockImplementation(
- () => '/de/neuigkeiten/launch-party-3'
- );
- rerender();
- screen.getByText('/news/[articleSlug]-[articleId]');
- });
-
- it('handles unknown route', () => {
- function Component() {
- const pathname = usePathname();
- return <>{pathname}>;
- }
- vi.mocked(useNextPathname).mockImplementation(() => '/en/unknown');
- const {rerender} = render();
- screen.getByText('/unknown');
-
- vi.mocked(useNextPathname).mockImplementation(() => '/de/unknown');
- rerender();
- screen.getByText('/de/unknown');
- });
-});
-
-describe('Link', () => {
- it('renders an href', () => {
- render(About);
- expect(screen.getByRole('link', {name: 'About'}).getAttribute('href')).toBe(
- '/about'
- );
- });
-
- it('adds a prefix when linking to a non-default locale', () => {
- render(
-
- Über uns
-
- );
- expect(
- screen.getByRole('link', {name: 'Über uns'}).getAttribute('href')
- ).toBe('/de/ueber-uns');
- });
-
- it('handles params', () => {
- render(
-
- About
-
- );
- expect(screen.getByRole('link', {name: 'About'}).getAttribute('href')).toBe(
- '/de/neuigkeiten/launch-party-3'
- );
- });
-
- it('handles catch-all segments', () => {
- render(
-
- Test
-
- );
- expect(screen.getByRole('link', {name: 'Test'}).getAttribute('href')).toBe(
- '/categories/clothing/t-shirts'
- );
- });
-
- it('handles optional catch-all segments', () => {
- render(
-
- Test
-
- );
- expect(screen.getByRole('link', {name: 'Test'}).getAttribute('href')).toBe(
- '/catch-all/one/two'
- );
- });
+describe("localePrefix: 'as-needed'", () => {
+ const {Link, redirect, usePathname, useRouter} =
+ createLocalizedPathnamesNavigation({
+ locales,
+ pathnames
+ });
- it('supports optional search params', () => {
- render(
-
- Test
-
- );
- expect(screen.getByRole('link', {name: 'Test'}).getAttribute('href')).toBe(
- '/about?foo=bar&bar=1&bar=2'
- );
+ describe('Link', () => {
+ it('supports receiving a ref', () => {
+ const ref = React.createRef();
+ render();
+ expect(ref.current).not.toBe(null);
+ });
});
- it('handles unknown routes', () => {
- // @ts-expect-error -- Unknown route
- const {rerender} = render(Unknown);
- expect(
- screen.getByRole('link', {name: 'Unknown'}).getAttribute('href')
- ).toBe('/unknown');
-
- rerender(
- // @ts-expect-error -- Unknown route
-
- Unknown
-
- );
- expect(
- screen.getByRole('link', {name: 'Unknown'}).getAttribute('href')
- ).toBe('/de/unknown');
- });
-});
+ describe('usePathname', () => {
+ it('returns the internal pathname for the default locale', () => {
+ function Component() {
+ const pathname = usePathname();
+ return <>{pathname}>;
+ }
+ vi.mocked(useNextPathname).mockImplementation(() => '/');
+ const {rerender} = render();
+ screen.getByText('/');
+
+ vi.mocked(useNextPathname).mockImplementation(() => '/about');
+ rerender();
+ screen.getByText('/about');
+
+ vi.mocked(useNextPathname).mockImplementation(
+ () => '/news/launch-party-3'
+ );
+ rerender();
+ screen.getByText('/news/[articleSlug]-[articleId]');
+ });
-describe('getPathname', () => {
- it('resolves to the correct path', () => {
- expect(
- getPathname({
- locale: 'en',
- href: {
- pathname: '/categories/[...parts]',
- params: {parts: ['clothing', 't-shirts']},
- query: {sort: 'price'}
- }
- })
- ).toBe('/categories/clothing/t-shirts?sort=price');
- });
-});
+ it('returns the internal pathname a non-default locale', () => {
+ vi.mocked(useParams).mockImplementation(() => ({locale: 'de'}));
-describe('useRouter', () => {
- describe('push', () => {
- it('resolves to the correct path when passing another locale', () => {
function Component() {
- const router = useRouter();
- router.push('/about', {locale: 'de'});
- return null;
+ const pathname = usePathname();
+ return <>{pathname}>;
}
- render();
- const push = useNextRouter().push as Mock;
- expect(push).toHaveBeenCalledTimes(1);
- expect(push).toHaveBeenCalledWith('/de/ueber-uns');
+ vi.mocked(useNextPathname).mockImplementation(() => '/de');
+ const {rerender} = render();
+ screen.getByText('/');
+
+ vi.mocked(useNextPathname).mockImplementation(() => '/de/ueber-uns');
+ rerender();
+ screen.getByText('/about');
+
+ vi.mocked(useNextPathname).mockImplementation(
+ () => '/de/neuigkeiten/launch-party-3'
+ );
+ rerender();
+ screen.getByText('/news/[articleSlug]-[articleId]');
});
- it('supports optional search params', () => {
+ it('handles unknown routes', () => {
function Component() {
- const router = useRouter();
- router.push(
- {
- pathname: '/about',
- query: {
- foo: 'bar',
- bar: [1, 2]
- }
- },
- {locale: 'de'}
- );
- return null;
+ const pathname = usePathname();
+ return <>{pathname}>;
}
- render();
- const push = useNextRouter().push as Mock;
- expect(push).toHaveBeenCalledTimes(1);
- expect(push).toHaveBeenCalledWith('/de/ueber-uns?foo=bar&bar=1&bar=2');
+ vi.mocked(useNextPathname).mockImplementation(() => '/en/unknown');
+ const {rerender} = render();
+ screen.getByText('/unknown');
+
+ vi.mocked(useNextPathname).mockImplementation(() => '/de/unknown');
+ rerender();
+ screen.getByText('/de/unknown');
+ });
+ });
+
+ describe('useRouter', () => {
+ describe('push', () => {
+ it('resolves to the correct path when passing another locale', () => {
+ function Component() {
+ const router = useRouter();
+ router.push('/about', {locale: 'de'});
+ return null;
+ }
+ render();
+ const push = useNextRouter().push as Mock;
+ expect(push).toHaveBeenCalledTimes(1);
+ expect(push).toHaveBeenCalledWith('/de/ueber-uns');
+ });
+
+ it('supports optional search params', () => {
+ function Component() {
+ const router = useRouter();
+ router.push(
+ {
+ pathname: '/about',
+ query: {
+ foo: 'bar',
+ bar: [1, 2]
+ }
+ },
+ {locale: 'de'}
+ );
+ return null;
+ }
+ render();
+ const push = useNextRouter().push as Mock;
+ expect(push).toHaveBeenCalledTimes(1);
+ expect(push).toHaveBeenCalledWith('/de/ueber-uns?foo=bar&bar=1&bar=2');
+ });
+
+ it('passes through unknown options to the Next.js router', () => {
+ function Component() {
+ const router = useRouter();
+ // @ts-expect-error -- Wait for https://github.com/vercel/next.js/pull/59001
+ router.push('/about', {locale: 'de', scroll: false});
+ return null;
+ }
+ render();
+ const push = useNextRouter().push as Mock;
+ expect(push).toHaveBeenCalledTimes(1);
+ expect(push).toHaveBeenCalledWith('/de/ueber-uns', {scroll: false});
+ });
});
- it('passes through unknown options to the Next.js router', () => {
+ it('handles unknown routes', () => {
function Component() {
const router = useRouter();
- // @ts-expect-error -- Wait for https://github.com/vercel/next.js/pull/59001
- router.push('/about', {locale: 'de', scroll: false});
+ // @ts-expect-error -- Unknown route
+ router.push('/unknown');
return null;
}
render();
const push = useNextRouter().push as Mock;
expect(push).toHaveBeenCalledTimes(1);
- expect(push).toHaveBeenCalledWith('/de/ueber-uns', {scroll: false});
+ expect(push).toHaveBeenCalledWith('/unknown');
});
});
- it('handles unknown routes', () => {
- function Component() {
- const router = useRouter();
- // @ts-expect-error -- Unknown route
- router.push('/unknown');
- return null;
- }
- render();
- const push = useNextRouter().push as Mock;
- expect(push).toHaveBeenCalledTimes(1);
- expect(push).toHaveBeenCalledWith('/unknown');
- });
-});
-
-/**
- * Type tests
- */
-
-// eslint-disable-next-line @typescript-eslint/no-unused-vars
-function TypeTests() {
- const router = useRouter();
-
- // @ts-expect-error -- Unknown route
- router.push('/unknown');
-
- // Valid
- router.push('/about');
- router.push('/about', {locale: 'de'});
- router.push({pathname: '/about'});
- router.push('/catch-all/[[...parts]]');
-
- // @ts-expect-error -- Requires params
- router.push({pathname: '/news/[articleSlug]-[articleId]'});
+ /**
+ * Type tests
+ */
- router.push({
- pathname: '/news/[articleSlug]-[articleId]',
- // @ts-expect-error -- Missing param
- params: {
- articleId: 3
- }
- });
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ function TypeTests() {
+ const router = useRouter();
- // Valid
- router.push({
- pathname: '/news/[articleSlug]-[articleId]',
- params: {
- articleId: 3,
- articleSlug: 'launch-party'
- }
- });
+ // @ts-expect-error -- Unknown route
+ router.push('/unknown');
- // @ts-expect-error -- Doesn't accept params
- router.push({pathname: '/about', params: {foo: 'bar'}});
+ // Valid
+ router.push('/about');
+ router.push('/about', {locale: 'de'});
+ router.push({pathname: '/about'});
+ router.push('/catch-all/[[...parts]]');
- // @ts-expect-error -- Unknown locale
-
- Über uns
- ;
+ // @ts-expect-error -- Requires params
+ router.push({pathname: '/news/[articleSlug]-[articleId]'});
- // @ts-expect-error -- Unknown route
- About;
+ router.push({
+ pathname: '/news/[articleSlug]-[articleId]',
+ // @ts-expect-error -- Missing param
+ params: {
+ articleId: 3
+ }
+ });
- // @ts-expect-error -- Requires params
- About;
- // @ts-expect-error -- Requires params
- About;
+ // Valid
+ router.push({
+ pathname: '/news/[articleSlug]-[articleId]',
+ params: {
+ articleId: 3,
+ articleSlug: 'launch-party'
+ }
+ });
- // @ts-expect-error -- Params for different route
- About;
+ // @ts-expect-error -- Doesn't accept params
+ router.push({pathname: '/about', params: {foo: 'bar'}});
- // @ts-expect-error -- Doesn't accept params
- About;
+ // @ts-expect-error -- Unknown locale
+
+ Über uns
+ ;
- // @ts-expect-error -- Missing params
- Über uns;
+ // @ts-expect-error -- Unknown route
+ About;
+
+ // @ts-expect-error -- Requires params
+ About;
+ // @ts-expect-error -- Requires params
+ About;
+
+ // @ts-expect-error -- Params for different route
+ About;
+
+ // @ts-expect-error -- Doesn't accept params
+ About;
+
+ // @ts-expect-error -- Missing params
+ Über uns;
+
+ // Valid
+ Über uns;
+ Über uns;
+
+ Über uns
+ ;
+ Optional catch-all;
+
+ // Link composition
+ function WrappedLink(
+ props: ComponentProps>
+ ) {
+ return ;
+ }
+ About;
+
+ News
+ ;
+
+ // @ts-expect-error -- Requires params
+ News;
+
+ // Valid
+ redirect({pathname: '/about'});
+ redirect('/catch-all/[[...parts]]');
+ redirect({
+ pathname: '/catch-all/[[...parts]]',
+ params: {parts: ['one', 'two']}
+ });
- // Valid
- Über uns;
- Über uns;
-
- Über uns
- ;
- Optional catch-all;
-
- // Link composition
- function WrappedLink(
- props: ComponentProps>
- ) {
- return ;
+ articleId: 3
+ }
+ });
+
+ // Allow unknown routes
+ const {
+ Link: LinkWithUnknown,
+ redirect: redirectWithUnknown,
+ usePathname: usePathnameWithUnkown,
+ useRouter: useRouterWithUnknown
+ } = createLocalizedPathnamesNavigation({
+ locales,
+ // eslint-disable-next-line @typescript-eslint/ban-types
+ pathnames: pathnames as typeof pathnames & Record
+ });
+ Unknown;
+ redirectWithUnknown('/unknown');
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const pathnameWithUnknown: ReturnType =
+ '/unknown';
+ useRouterWithUnknown().push('/unknown');
}
- About;
-
- News
- ;
-
- // @ts-expect-error -- Requires params
- News;
-
- // Valid
- redirect({pathname: '/about'});
- redirect('/catch-all/[[...parts]]');
- redirect({
- pathname: '/catch-all/[[...parts]]',
- params: {parts: ['one', 'two']}
+});
+
+describe("localePrefix: 'never'", () => {
+ const {useRouter} = createLocalizedPathnamesNavigation({
+ pathnames,
+ locales,
+ localePrefix: 'never'
});
- // @ts-expect-error -- Unknown route
- redirect('/unknown');
- // @ts-expect-error -- Localized alternative
- redirect('/ueber-uns');
- // @ts-expect-error -- Requires params
- redirect('/news/[articleSlug]-[articleId]');
- redirect({
- pathname: '/news/[articleSlug]-[articleId]',
- // @ts-expect-error -- Missing param
- params: {
- articleId: 3
+ describe('useRouter', () => {
+ function Component({locale}: {locale?: string}) {
+ const router = useRouter();
+ router.push('/about', {locale});
+ return null;
}
- });
- // Allow unknown routes
- const {
- Link: LinkWithUnknown,
- redirect: redirectWithUnknown,
- usePathname: usePathnameWithUnkown,
- useRouter: useRouterWithUnknown
- } = createLocalizedPathnamesNavigation({
- locales,
- // eslint-disable-next-line @typescript-eslint/ban-types
- pathnames: pathnames as typeof pathnames & Record
+ describe('push', () => {
+ it('can push a pathname for the default locale', () => {
+ render();
+ const push = useNextRouter().push as Mock;
+ expect(push).toHaveBeenCalledTimes(1);
+ expect(push).toHaveBeenCalledWith('/about');
+ });
+
+ it('can push a pathname for a secondary locale', () => {
+ vi.mocked(useParams).mockImplementation(() => ({locale: 'de'}));
+ render();
+ const push = useNextRouter().push as Mock;
+ expect(push).toHaveBeenCalledTimes(1);
+ expect(push).toHaveBeenCalledWith('/ueber-uns');
+ });
+
+ it('resolves to the correct path when passing another locale', () => {
+ render();
+ const push = useNextRouter().push as Mock;
+ expect(push).toHaveBeenCalledTimes(1);
+ expect(push).toHaveBeenCalledWith('/de/ueber-uns');
+ });
+ });
});
- Unknown;
- redirectWithUnknown('/unknown');
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
- const pathnameWithUnknown: ReturnType =
- '/unknown';
- useRouterWithUnknown().push('/unknown');
-}
+});
diff --git a/packages/next-intl/test/navigation/react-client/createSharedPathnamesNavigation.test.tsx b/packages/next-intl/test/navigation/react-client/createSharedPathnamesNavigation.test.tsx
index 369f3e765..b5b1d8e84 100644
--- a/packages/next-intl/test/navigation/react-client/createSharedPathnamesNavigation.test.tsx
+++ b/packages/next-intl/test/navigation/react-client/createSharedPathnamesNavigation.test.tsx
@@ -1,4 +1,4 @@
-import {render, screen} from '@testing-library/react';
+import {render} from '@testing-library/react';
import {
usePathname,
useParams,
@@ -10,9 +10,7 @@ import createSharedPathnamesNavigation from '../../../src/navigation/react-clien
vi.mock('next/navigation');
-const {Link, useRouter} = createSharedPathnamesNavigation({
- locales: ['en', 'de'] as const
-});
+const locales = ['en', 'de'] as const;
beforeEach(() => {
const router = {
@@ -28,100 +26,116 @@ beforeEach(() => {
vi.mocked(useParams).mockImplementation(() => ({locale: 'en'}));
});
-describe('Link', () => {
- it('renders an href', () => {
- render(About);
- expect(screen.getByRole('link', {name: 'About'}).getAttribute('href')).toBe(
- '/about'
- );
+describe("localePrefix: 'as-needed'", () => {
+ const {Link, useRouter} = createSharedPathnamesNavigation({
+ locales,
+ localePrefix: 'as-needed'
});
- it('renders an object href', () => {
- render(About);
- expect(screen.getByRole('link', {name: 'About'}).getAttribute('href')).toBe(
- '/about?foo=bar'
- );
+ describe('Link', () => {
+ it('supports receiving a ref', () => {
+ const ref = React.createRef();
+ render();
+ expect(ref.current).not.toBe(null);
+ });
});
- it('adds a prefix when linking to a non-default locale', () => {
- render(
-
- Über uns
-
- );
- expect(
- screen.getByRole('link', {name: 'Über uns'}).getAttribute('href')
- ).toBe('/de/about');
+ describe('useRouter', () => {
+ describe('push', () => {
+ it('resolves to the correct path when passing another locale', () => {
+ function Component() {
+ const router = useRouter();
+ router.push('/about', {locale: 'de'});
+ return null;
+ }
+ render();
+ const push = useNextRouter().push as Mock;
+ expect(push).toHaveBeenCalledTimes(1);
+ expect(push).toHaveBeenCalledWith('/de/about');
+ });
+
+ it('passes through unknown options to the Next.js router', () => {
+ function Component() {
+ const router = useRouter();
+ // @ts-expect-error -- Wait for https://github.com/vercel/next.js/pull/59001
+ router.push('/about', {locale: 'de', scroll: false});
+ return null;
+ }
+ render();
+ const push = useNextRouter().push as Mock;
+ expect(push).toHaveBeenCalledTimes(1);
+ expect(push).toHaveBeenCalledWith('/de/about', {scroll: false});
+ });
+ });
});
- it('handles params', () => {
- render(
-
- About
-
- );
- expect(screen.getByRole('link', {name: 'About'}).getAttribute('href')).toBe(
- '/de/news/launch-party-3'
- );
- });
-});
+ /**
+ * Type tests
+ */
-describe('useRouter', () => {
- describe('push', () => {
- it('resolves to the correct path when passing another locale', () => {
- function Component() {
- const router = useRouter();
- router.push('/about', {locale: 'de'});
- return null;
- }
- render();
- const push = useNextRouter().push as Mock;
- expect(push).toHaveBeenCalledTimes(1);
- expect(push).toHaveBeenCalledWith('/de/about');
- });
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ function TypeTests() {
+ const router = useRouter();
- it('passes through unknown options to the Next.js router', () => {
- function Component() {
- const router = useRouter();
- // @ts-expect-error -- Wait for https://github.com/vercel/next.js/pull/59001
- router.push('/about', {locale: 'de', scroll: false});
- return null;
- }
- render();
- const push = useNextRouter().push as Mock;
- expect(push).toHaveBeenCalledTimes(1);
- expect(push).toHaveBeenCalledWith('/de/about', {scroll: false});
- });
- });
-});
-
-/**
- * Type tests
- */
+ // @ts-expect-error -- Only supports string paths
+ router.push({pathname: '/about'});
-// eslint-disable-next-line @typescript-eslint/no-unused-vars
-function TypeTests() {
- const router = useRouter();
+ // Valid
+ router.push('/about');
+ router.push('/about', {locale: 'de'});
+ router.push('/unknown'); // No error since routes are unknown
- // @ts-expect-error -- Only supports string paths
- router.push({pathname: '/about'});
+ // @ts-expect-error -- No params supported
+
+ User
+ ;
- // Valid
- router.push('/about');
- router.push('/about', {locale: 'de'});
- router.push('/unknown'); // No error since routes are unknown
+ // @ts-expect-error -- Unknown locale
+
+ User
+ ;
- // @ts-expect-error -- No params supported
-
- User
- ;
+ // Valid
+ Über uns;
+ About; // No error since routes are unknown
+ }
+});
- // @ts-expect-error -- Unknown locale
-
- User
- ;
+describe("localePrefix: 'never'", () => {
+ const {useRouter} = createSharedPathnamesNavigation({
+ locales,
+ localePrefix: 'never'
+ });
- // Valid
- Über uns;
- About; // No error since routes are unknown
-}
+ describe('useRouter', () => {
+ function Component({locale}: {locale?: string}) {
+ const router = useRouter();
+ router.push('/about', {locale});
+ return null;
+ }
+
+ describe('push', () => {
+ it('can push a pathname for the default locale', () => {
+ render();
+ const push = useNextRouter().push as Mock;
+ expect(push).toHaveBeenCalledTimes(1);
+ expect(push).toHaveBeenCalledWith('/about');
+ });
+
+ it('can push a pathname for a secondary locale', () => {
+ vi.mocked(useParams).mockImplementation(() => ({locale: 'de'}));
+ render();
+ const push = useNextRouter().push as Mock;
+ expect(push).toHaveBeenCalledTimes(1);
+ expect(push).toHaveBeenCalledWith('/about');
+ });
+
+ it('resolves to the correct path when passing another locale', () => {
+ render();
+ const push = useNextRouter().push as Mock;
+ expect(push).toHaveBeenCalledTimes(1);
+ expect(push).toHaveBeenCalledWith('/de/about');
+ });
+ });
+ });
+});
diff --git a/packages/next-intl/test/shared/utils.test.tsx b/packages/next-intl/test/shared/utils.test.tsx
index 7d84bd415..3d398f150 100644
--- a/packages/next-intl/test/shared/utils.test.tsx
+++ b/packages/next-intl/test/shared/utils.test.tsx
@@ -3,20 +3,20 @@ import {
hasPathnamePrefixed,
unlocalizePathname,
matchesPathname,
- localizePathname
+ prefixPathname
} from '../../src/shared/utils';
-describe('localizePathname', () => {
+describe('prefixPathname', () => {
it("doesn't add trailing slashes for the root", () => {
- expect(localizePathname('en', '/')).toEqual('/en');
+ expect(prefixPathname('en', '/')).toEqual('/en');
});
it("doesn't add trailing slashes for search params", () => {
- expect(localizePathname('en', '/?foo=bar')).toEqual('/en?foo=bar');
+ expect(prefixPathname('en', '/?foo=bar')).toEqual('/en?foo=bar');
});
it('localizes nested paths', () => {
- expect(localizePathname('en', '/nested')).toEqual('/en/nested');
+ expect(prefixPathname('en', '/nested')).toEqual('/en/nested');
});
});
diff --git a/packages/use-intl/src/core/utils/NestedValueOf.tsx b/packages/use-intl/src/core/utils/NestedValueOf.tsx
index c048df56f..4d396f4a3 100644
--- a/packages/use-intl/src/core/utils/NestedValueOf.tsx
+++ b/packages/use-intl/src/core/utils/NestedValueOf.tsx
@@ -6,7 +6,7 @@ type NestedValueOf<
? NestedValueOf
: never
: Property extends keyof ObjectType
- ? ObjectType[Property]
- : never;
+ ? ObjectType[Property]
+ : never;
export default NestedValueOf;
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 0189f22c4..3441b1565 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -519,6 +519,9 @@ importers:
'@types/react':
specifier: 18.2.34
version: 18.2.34
+ '@types/react-dom':
+ specifier: ^18.2.17
+ version: 18.2.17
eslint:
specifier: ^8.54.0
version: 8.54.0
@@ -7459,7 +7462,7 @@ packages:
dependencies:
'@babel/runtime': 7.21.5
'@testing-library/dom': 8.20.0
- '@types/react-dom': 18.2.14
+ '@types/react-dom': 18.2.17
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
dev: true
@@ -7760,6 +7763,12 @@ packages:
'@types/react': 18.2.34
dev: true
+ /@types/react-dom@18.2.17:
+ resolution: {integrity: sha512-rvrT/M7Df5eykWFxn6MYt5Pem/Dbyc1N8Y0S9Mrkw2WFCRiqUgw9P7ul2NpwsXCSM1DVdENzdG9J5SreqfAIWg==}
+ dependencies:
+ '@types/react': 18.2.34
+ dev: true
+
/@types/react-dom@18.2.2:
resolution: {integrity: sha512-IGuuCsLmAH0f3KksOZp/vkpUtO2YrIwob4YxvoFQR2XvkLL7tf7mLYcXiyG47KgTKngI4+7lNm4dM4eBTbG1Bw==}
dependencies:
@@ -12454,13 +12463,13 @@ packages:
eslint-plugin-import: 2.27.5(@typescript-eslint/parser@6.4.1)(eslint@8.54.0)
eslint-plugin-jest: 27.2.3(@typescript-eslint/eslint-plugin@6.4.1)(eslint@8.54.0)(jest@29.5.0)(typescript@5.2.2)
eslint-plugin-jsx-a11y: 6.7.1(eslint@8.54.0)
- eslint-plugin-prettier: 5.0.0(eslint@8.54.0)(prettier@3.0.2)
+ eslint-plugin-prettier: 5.0.0(eslint@8.54.0)(prettier@3.1.0)
eslint-plugin-react: 7.33.2(eslint@8.54.0)
eslint-plugin-react-hooks: 4.6.0(eslint@8.54.0)
eslint-plugin-sort-destructure-keys: 1.5.0(eslint@8.54.0)
eslint-plugin-tailwindcss: 3.13.0(tailwindcss@3.3.3)
eslint-plugin-unicorn: 48.0.1(eslint@8.54.0)
- prettier: 3.0.2
+ prettier: 3.1.0
transitivePeerDependencies:
- '@types/eslint'
- eslint-config-prettier
@@ -12806,26 +12815,6 @@ packages:
semver: 6.3.1
dev: true
- /eslint-plugin-prettier@5.0.0(eslint@8.54.0)(prettier@3.0.2):
- resolution: {integrity: sha512-AgaZCVuYDXHUGxj/ZGu1u8H8CYgDY3iG6w5kUFw4AzMVXzB7VvbKgYR4nATIN+OvUrghMbiDLeimVjVY5ilq3w==}
- engines: {node: ^14.18.0 || >=16.0.0}
- peerDependencies:
- '@types/eslint': '>=8.0.0'
- eslint: '>=8.0.0'
- eslint-config-prettier: '*'
- prettier: '>=3.0.0'
- peerDependenciesMeta:
- '@types/eslint':
- optional: true
- eslint-config-prettier:
- optional: true
- dependencies:
- eslint: 8.54.0
- prettier: 3.0.2
- prettier-linter-helpers: 1.0.0
- synckit: 0.8.5
- dev: true
-
/eslint-plugin-prettier@5.0.0(eslint@8.54.0)(prettier@3.1.0):
resolution: {integrity: sha512-AgaZCVuYDXHUGxj/ZGu1u8H8CYgDY3iG6w5kUFw4AzMVXzB7VvbKgYR4nATIN+OvUrghMbiDLeimVjVY5ilq3w==}
engines: {node: ^14.18.0 || >=16.0.0}
@@ -21208,12 +21197,6 @@ packages:
hasBin: true
dev: true
- /prettier@3.0.2:
- resolution: {integrity: sha512-o2YR9qtniXvwEZlOKbveKfDQVyqxbEIWn48Z8m3ZJjBjcCmUy3xZGIv+7AkaeuaTr6yPXJjwv07ZWlsWbEy1rQ==}
- engines: {node: '>=14'}
- hasBin: true
- dev: true
-
/prettier@3.1.0:
resolution: {integrity: sha512-TQLvXjq5IAibjh8EpBIkNKxO749UEWABoiIZehEPiY4GNpVdhaFKqSTu+QrlU6D2dPAfubRmtJTi4K4YkQ5eXw==}
engines: {node: '>=14'}
@@ -24322,7 +24305,7 @@ packages:
execa: 5.1.1
globby: 11.1.0
joycon: 3.1.1
- postcss-load-config: 4.0.1(postcss@8.4.24)
+ postcss-load-config: 4.0.1(postcss@8.4.31)
resolve-from: 5.0.0
rollup: 3.28.1
source-map: 0.8.0-beta.0