Skip to content

Commit 2d93d65

Browse files
authored
feat: createNavigation (amannn#1316)
This PR provides a new **`createNavigation`** function that supersedes the previously available APIs: 1. `createSharedPathnamesNavigation` 2. `createLocalizedPathnamesNavigation` The new function unifies the API for both use cases and also fixes a few quirks in the previous APIs. **Usage** ```tsx import {createNavigation} from 'next-intl/navigation'; import {defineRouting} from 'next-intl/routing'; export const routing = defineRouting(/* ... */); export const {Link, redirect, usePathname, useRouter} = createNavigation(routing); ``` (see the [updated navigation docs](https://next-intl-docs-git-feat-create-navigation-next-intl.vercel.app/docs/routing/navigation)) **Improvements** 1. A single API can be used both for shared as well as localized pathnames. This reduces the API surface and simplifies the corresponding docs. 2. `Link` can now be composed seamlessly into another component with its `href` prop without having to add a generic type argument. 3. `getPathname` is now available for both shared as well as localized pathnames (fixes amannn#785) 4. `router.push` and `redirect` now accept search params consistently via the object form (e.g. `router.push({pathname: '/users', query: {sortBy: 'name'})`)—regardless of if you're using shared or localized pathnames. 5. When using `localePrefix: 'as-necessary'`, the initial render of `Link` now uses the correct pathname immediately during SSR (fixes [amannn#444](amannn#444)). Previously, a prefix for the default locale was added during SSR and removed during hydration. Also `redirect` now gets the final pathname right without having to add a superfluous prefix (fixes [amannn#1335](amannn#1335)). The only exception is when you use `localePrefix: 'as-necessary'` in combination with `domains` (see [Special case: Using `domains` with `localePrefix: 'as-needed'`](https://next-intl-docs-git-feat-create-navigation-next-intl.vercel.app/docs/routing#domains-localeprefix-asneeded)) 6. `Link` is now compatible with the `asChild` prop of Radix Primitives when rendered in RSC (see [amannn#1322](amannn#1322)) **Migrating to `createNavigation`** `createNavigation` is generally considered a drop-in replacement, but a few changes might be necessary: 1. `createNavigation` is expected to receive your complete routing configuration. Ideally, you define this via the [`defineRouting`](https://next-intl-docs.vercel.app/docs/routing#define-routing) function and pass the result to `createNavigation`. 2. If you've used `createLocalizedPathnamesNavigation` and have [composed the `Link` with its `href` prop](https://next-intl-docs.vercel.app/docs/routing/navigation#link-composition), you should no longer provide the generic `Pathname` type argument (see [updated docs](https://next-intl-docs-git-feat-create-navigation-next-intl.vercel.app/docs/routing/navigation#link-composition)). ```diff - ComponentProps<typeof Link<Pathname>> + ComponentProps<typeof Link> ``` 3. If you've used [`redirect`](https://next-intl-docs.vercel.app/docs/routing/navigation#redirect), you now have to provide an explicit locale (even if it's just [the current locale](https://next-intl-docs.vercel.app/docs/usage/configuration#locale)). This change was necessary for an upcoming change in Next.js 15 where `headers()` turns into a promise (see [amannn#1375](amannn#1375) for details). ```diff - redirect('/about') + redirect({pathname: '/about', locale: 'en'}) ``` 4. If you've used [`getPathname`](https://next-intl-docs.vercel.app/docs/routing/navigation#getpathname) and have previously manually prepended a locale prefix, you should no longer do so—`getPathname` now takes care of this depending on your routing strategy. ```diff - '/'+ locale + getPathname(/* ... */) + getPathname(/* ... */); ``` 5. If you're using a combination of `localePrefix: 'as-necessary'` and `domains` and you're using `getPathname`, you now need to provide a `domain` argument (see [Special case: Using `domains` with `localePrefix: 'as-needed'`](https://next-intl-docs-git-feat-create-navigation-next-intl.vercel.app/docs/routing#domains-localeprefix-asneeded))
1 parent a2d6478 commit 2d93d65

39 files changed

+2907
-208
lines changed

.eslintrc.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ module.exports = {
88
plugins: ['deprecation', 'eslint-plugin-react-compiler'],
99
rules: {
1010
'import/no-useless-path-segments': 'error',
11-
'react-compiler/react-compiler': 'error'
11+
'react-compiler/react-compiler': 'error',
12+
'@typescript-eslint/ban-types': 'off'
1213
},
1314
overrides: [
1415
{

.size-limit.ts

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,43 +2,79 @@ import type {SizeLimitConfig} from 'size-limit';
22

33
const config: SizeLimitConfig = [
44
{
5+
name: 'import * from \'next-intl\' (react-client)',
56
path: 'dist/production/index.react-client.js',
67
limit: '14.095 KB'
78
},
89
{
10+
name: 'import * from \'next-intl\' (react-server)',
911
path: 'dist/production/index.react-server.js',
1012
limit: '14.665 KB'
1113
},
1214
{
15+
name: 'import {createSharedPathnamesNavigation} from \'next-intl/navigation\' (react-client)',
1316
path: 'dist/production/navigation.react-client.js',
14-
limit: '3.155 KB'
17+
import: '{createSharedPathnamesNavigation}',
18+
limit: '3.885 KB'
1519
},
1620
{
21+
name: 'import {createLocalizedPathnamesNavigation} from \'next-intl/navigation\' (react-client)',
22+
path: 'dist/production/navigation.react-client.js',
23+
import: '{createLocalizedPathnamesNavigation}',
24+
limit: '3.885 KB'
25+
},
26+
{
27+
name: 'import {createNavigation} from \'next-intl/navigation\' (react-client)',
28+
path: 'dist/production/navigation.react-client.js',
29+
import: '{createNavigation}',
30+
limit: '3.885 KB'
31+
},
32+
{
33+
name: 'import {createSharedPathnamesNavigation} from \'next-intl/navigation\' (react-server)',
34+
path: 'dist/production/navigation.react-server.js',
35+
import: '{createSharedPathnamesNavigation}',
36+
limit: '16.515 KB'
37+
},
38+
{
39+
name: 'import {createLocalizedPathnamesNavigation} from \'next-intl/navigation\' (react-server)',
40+
path: 'dist/production/navigation.react-server.js',
41+
import: '{createLocalizedPathnamesNavigation}',
42+
limit: '16.545 KB'
43+
},
44+
{
45+
name: 'import {createNavigation} from \'next-intl/navigation\' (react-server)',
1746
path: 'dist/production/navigation.react-server.js',
18-
limit: '15.845 KB'
47+
import: '{createNavigation}',
48+
limit: '16.495 KB'
1949
},
2050
{
51+
name: 'import * from \'next-intl/server\' (react-client)',
2152
path: 'dist/production/server.react-client.js',
2253
limit: '1 KB'
2354
},
2455
{
56+
name: 'import * from \'next-intl/server\' (react-server)',
2557
path: 'dist/production/server.react-server.js',
2658
limit: '13.865 KB'
2759
},
2860
{
61+
name: 'import createMiddleware from \'next-intl/middleware\'',
2962
path: 'dist/production/middleware.js',
30-
limit: '9.625 KB'
63+
limit: '9.63 KB'
3164
},
3265
{
66+
name: 'import * from \'next-intl/routing\'',
3367
path: 'dist/production/routing.js',
3468
limit: '1 KB'
3569
},
3670
{
71+
name: 'import * from \'next-intl\' (react-client, ESM)',
3772
path: 'dist/esm/index.react-client.js',
3873
import: '*',
3974
limit: '14.265 kB'
4075
},
4176
{
77+
name: 'import {NextIntlProvider} from \'next-intl\' (react-client, ESM)',
4278
path: 'dist/esm/index.react-client.js',
4379
import: '{NextIntlClientProvider}',
4480
limit: '1.425 kB'

src/middleware/getAlternateLinksHeaderValue.tsx

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import {NextRequest} from 'next/server';
22
import {ResolvedRoutingConfig} from '../routing/config';
3-
import {Locales, Pathnames} from '../routing/types';
3+
import {
4+
DomainsConfig,
5+
LocalePrefixMode,
6+
Locales,
7+
Pathnames
8+
} from '../routing/types';
49
import {normalizeTrailingSlash} from '../shared/utils';
510
import {
611
applyBasePath,
@@ -16,14 +21,24 @@ import {
1621
*/
1722
export default function getAlternateLinksHeaderValue<
1823
AppLocales extends Locales,
19-
AppPathnames extends Pathnames<AppLocales> = never
24+
AppLocalePrefixMode extends LocalePrefixMode,
25+
AppPathnames extends Pathnames<AppLocales> | undefined,
26+
AppDomains extends DomainsConfig<AppLocales> | undefined
2027
>({
2128
localizedPathnames,
2229
request,
2330
resolvedLocale,
2431
routing
2532
}: {
26-
routing: ResolvedRoutingConfig<AppLocales, AppPathnames>;
33+
routing: Omit<
34+
ResolvedRoutingConfig<
35+
AppLocales,
36+
AppLocalePrefixMode,
37+
AppPathnames,
38+
AppDomains
39+
>,
40+
'pathnames'
41+
>;
2742
request: NextRequest;
2843
resolvedLocale: AppLocales[number];
2944
localizedPathnames?: Pathnames<AppLocales>[string];

src/middleware/middleware.test.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1506,7 +1506,7 @@ describe('prefix-based routing', () => {
15061506
'renders a localized pathname where the internal pathname was defined with a trailing slash',
15071507
(pathname) => {
15081508
createMiddleware({
1509-
defaultLocale: 'en',
1509+
defaultLocale: 'de',
15101510
locales: ['de'],
15111511
localePrefix: 'always',
15121512
pathnames: {
@@ -1526,7 +1526,7 @@ describe('prefix-based routing', () => {
15261526
'redirects a localized pathname where the internal pathname was defined with a trailing slash',
15271527
(pathname) => {
15281528
createMiddleware({
1529-
defaultLocale: 'en',
1529+
defaultLocale: 'de',
15301530
locales: ['de'],
15311531
localePrefix: 'always',
15321532
pathnames: {

src/middleware/middleware.tsx

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import {NextRequest, NextResponse} from 'next/server';
22
import {receiveRoutingConfig, RoutingConfig} from '../routing/config';
3-
import {Locales, Pathnames} from '../routing/types';
3+
import {
4+
DomainsConfig,
5+
LocalePrefixMode,
6+
Locales,
7+
Pathnames
8+
} from '../routing/types';
49
import {HEADER_LOCALE_NAME} from '../shared/constants';
510
import {
611
getLocalePrefix,
@@ -26,9 +31,16 @@ import {
2631

2732
export default function createMiddleware<
2833
AppLocales extends Locales,
29-
AppPathnames extends Pathnames<AppLocales> = never
34+
AppLocalePrefixMode extends LocalePrefixMode = 'always',
35+
AppPathnames extends Pathnames<AppLocales> = never,
36+
AppDomains extends DomainsConfig<AppLocales> = never
3037
>(
31-
routing: RoutingConfig<AppLocales, AppPathnames> &
38+
routing: RoutingConfig<
39+
AppLocales,
40+
AppLocalePrefixMode,
41+
AppPathnames,
42+
AppDomains
43+
> &
3244
// Convenience if `routing` is generated dynamically (i.e. without `defineRouting`)
3345
MiddlewareOptions,
3446
options?: MiddlewareOptions
@@ -156,16 +168,19 @@ export default function createMiddleware<
156168
let internalTemplateName: keyof AppPathnames | undefined;
157169

158170
let unprefixedInternalPathname = unprefixedExternalPathname;
159-
if ('pathnames' in resolvedRouting) {
171+
const pathnames = (resolvedRouting as any).pathnames as
172+
| AppPathnames
173+
| undefined;
174+
if (pathnames) {
160175
let resolvedTemplateLocale: AppLocales[number] | undefined;
161176
[resolvedTemplateLocale, internalTemplateName] = getInternalTemplate(
162-
resolvedRouting.pathnames,
177+
pathnames,
163178
unprefixedExternalPathname,
164179
locale
165180
);
166181

167182
if (internalTemplateName) {
168-
const pathnameConfig = resolvedRouting.pathnames[internalTemplateName];
183+
const pathnameConfig = pathnames[internalTemplateName];
169184
const localeTemplate: string =
170185
typeof pathnameConfig === 'string'
171186
? pathnameConfig
@@ -310,8 +325,8 @@ export default function createMiddleware<
310325
getAlternateLinksHeaderValue({
311326
routing: resolvedRouting,
312327
localizedPathnames:
313-
internalTemplateName! != null && 'pathnames' in resolvedRouting
314-
? resolvedRouting.pathnames?.[internalTemplateName]
328+
internalTemplateName! != null && pathnames
329+
? pathnames?.[internalTemplateName]
315330
: undefined,
316331
request,
317332
resolvedLocale: locale

src/middleware/resolveLocale.tsx

Lines changed: 39 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ import {
66
Locales,
77
Pathnames,
88
DomainsConfig,
9-
DomainConfig
9+
DomainConfig,
10+
LocalePrefixMode
1011
} from '../routing/types';
1112
import {COOKIE_LOCALE_NAME} from '../shared/constants';
1213
import {ResolvedMiddlewareOptions} from './config';
@@ -71,13 +72,23 @@ function getLocaleFromCookie<AppLocales extends Locales>(
7172

7273
function resolveLocaleFromPrefix<
7374
AppLocales extends Locales,
74-
AppPathnames extends Pathnames<AppLocales> = never
75+
AppLocalePrefixMode extends LocalePrefixMode,
76+
AppPathnames extends Pathnames<AppLocales> | undefined,
77+
AppDomains extends DomainsConfig<AppLocales> | undefined
7578
>(
7679
{
7780
defaultLocale,
7881
localePrefix,
7982
locales
80-
}: ResolvedRoutingConfig<AppLocales, AppPathnames>,
83+
}: Omit<
84+
ResolvedRoutingConfig<
85+
AppLocales,
86+
AppLocalePrefixMode,
87+
AppPathnames,
88+
AppDomains
89+
>,
90+
'pathnames'
91+
>,
8192
{localeDetection}: ResolvedMiddlewareOptions,
8293
requestHeaders: Headers,
8394
requestCookies: RequestCookies,
@@ -110,10 +121,19 @@ function resolveLocaleFromPrefix<
110121

111122
function resolveLocaleFromDomain<
112123
AppLocales extends Locales,
113-
AppPathnames extends Pathnames<AppLocales> = never
124+
AppLocalePrefixMode extends LocalePrefixMode,
125+
AppPathnames extends Pathnames<AppLocales> | undefined,
126+
AppDomains extends DomainsConfig<AppLocales> | undefined
114127
>(
115-
routing: Omit<ResolvedRoutingConfig<AppLocales, AppPathnames>, 'domains'> &
116-
Required<Pick<ResolvedRoutingConfig<AppLocales, AppPathnames>, 'domains'>>,
128+
routing: Omit<
129+
ResolvedRoutingConfig<
130+
AppLocales,
131+
AppLocalePrefixMode,
132+
AppPathnames,
133+
AppDomains
134+
>,
135+
'pathnames'
136+
>,
117137
options: ResolvedMiddlewareOptions,
118138
requestHeaders: Headers,
119139
requestCookies: RequestCookies,
@@ -188,24 +208,27 @@ function resolveLocaleFromDomain<
188208

189209
export default function resolveLocale<
190210
AppLocales extends Locales,
191-
AppPathnames extends Pathnames<AppLocales> = never
211+
AppLocalePrefixMode extends LocalePrefixMode,
212+
AppPathnames extends Pathnames<AppLocales> | undefined,
213+
AppDomains extends DomainsConfig<AppLocales> | undefined
192214
>(
193-
routing: ResolvedRoutingConfig<AppLocales, AppPathnames>,
215+
routing: Omit<
216+
ResolvedRoutingConfig<
217+
AppLocales,
218+
AppLocalePrefixMode,
219+
AppPathnames,
220+
AppDomains
221+
>,
222+
'pathnames'
223+
>,
194224
options: ResolvedMiddlewareOptions,
195225
requestHeaders: Headers,
196226
requestCookies: RequestCookies,
197227
pathname: string
198228
): {locale: AppLocales[number]; domain?: DomainConfig<AppLocales>} {
199229
if (routing.domains) {
200-
const routingWithDomains = routing as Omit<
201-
ResolvedRoutingConfig<AppLocales, AppPathnames>,
202-
'domains'
203-
> &
204-
Required<
205-
Pick<ResolvedRoutingConfig<AppLocales, AppPathnames>, 'domains'>
206-
>;
207230
return resolveLocaleFromDomain(
208-
routingWithDomains,
231+
routing,
209232
options,
210233
requestHeaders,
211234
requestCookies,

src/middleware/utils.tsx

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import {
33
LocalePrefixConfigVerbose,
44
DomainConfig,
55
Pathnames,
6-
DomainsConfig
6+
DomainsConfig,
7+
LocalePrefixMode
78
} from '../routing/types';
89
import {
910
getLocalePrefix,
@@ -92,10 +93,13 @@ export function formatTemplatePathname(
9293
/**
9394
* Removes potential prefixes from the pathname.
9495
*/
95-
export function getNormalizedPathname<AppLocales extends Locales>(
96+
export function getNormalizedPathname<
97+
AppLocales extends Locales,
98+
AppLocalePrefixMode extends LocalePrefixMode
99+
>(
96100
pathname: string,
97101
locales: AppLocales,
98-
localePrefix: LocalePrefixConfigVerbose<AppLocales>
102+
localePrefix: LocalePrefixConfigVerbose<AppLocales, AppLocalePrefixMode>
99103
) {
100104
// Add trailing slash for consistent handling
101105
// both for the root as well as nested paths
@@ -127,9 +131,12 @@ export function findCaseInsensitiveString(
127131
return strings.find((cur) => cur.toLowerCase() === candidate.toLowerCase());
128132
}
129133

130-
export function getLocalePrefixes<AppLocales extends Locales>(
134+
export function getLocalePrefixes<
135+
AppLocales extends Locales,
136+
AppLocalePrefixMode extends LocalePrefixMode
137+
>(
131138
locales: AppLocales,
132-
localePrefix: LocalePrefixConfigVerbose<AppLocales>,
139+
localePrefix: LocalePrefixConfigVerbose<AppLocales, AppLocalePrefixMode>,
133140
sort = true
134141
): Array<[AppLocales[number], string]> {
135142
const prefixes = locales.map((locale) => [
@@ -145,10 +152,13 @@ export function getLocalePrefixes<AppLocales extends Locales>(
145152
return prefixes as Array<[AppLocales[number], string]>;
146153
}
147154

148-
export function getPathnameMatch<AppLocales extends Locales>(
155+
export function getPathnameMatch<
156+
AppLocales extends Locales,
157+
AppLocalePrefixMode extends LocalePrefixMode
158+
>(
149159
pathname: string,
150160
locales: AppLocales,
151-
localePrefix: LocalePrefixConfigVerbose<AppLocales>
161+
localePrefix: LocalePrefixConfigVerbose<AppLocales, AppLocalePrefixMode>
152162
):
153163
| {
154164
locale: AppLocales[number];

src/navigation/createLocalizedPathnamesNavigation.test.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {getRequestLocale} from '../server/react-server/RequestLocale';
1414
import {getLocalePrefix} from '../shared/utils';
1515
import createLocalizedPathnamesNavigationClient from './react-client/createLocalizedPathnamesNavigation';
1616
import createLocalizedPathnamesNavigationServer from './react-server/createLocalizedPathnamesNavigation';
17-
import BaseLink from './shared/BaseLink';
17+
import LegacyBaseLink from './shared/LegacyBaseLink';
1818

1919
vi.mock('next/navigation', async () => {
2020
const actual = await vi.importActual('next/navigation');
@@ -39,7 +39,7 @@ vi.mock('../../src/navigation/react-server/ServerLink', () => ({
3939
const finalLocale = locale || 'en';
4040
const prefix = getLocalePrefix(finalLocale, localePrefix);
4141
return (
42-
<BaseLink
42+
<LegacyBaseLink
4343
locale={finalLocale}
4444
localePrefixMode={localePrefix.mode}
4545
prefix={prefix}

0 commit comments

Comments
 (0)