Skip to content

Commit 15c826b

Browse files
authored
fix!: Return x-default alternate link also for sub pages when using localePrefix: 'always' and update middleware matcher suggestion in docs (#1720)
Previously, we suggested a middleware matcher that looked like this: ```tsx // middleware.ts export const config = { // Match only internationalized pathnames matcher: ['/', '/(de|en)/:path*'] }; ``` Even though the hardcoded locales need to be updated when new locales are added, this was suggested in light of providing an error-free getting started experience. However, based on the apps I've seen over time, it seems like this choice was unpopular and users typically go for a matcher that looks like this: ```tsx export const config = { // Match all pathnames except for // - … if they start with `/api`, `/_next` or `/_vercel` // - … the ones containing a dot (e.g. `favicon.ico`) matcher: '/((?!api|_next|_vercel|.*\\..*).*)' }; ``` While this avoids hardcoding locales, it requires extra care to [match pathnames that contain a dot](https://next-intl.dev/docs/routing/middleware#matcher-config) (e.g. `/users/jane.doe`). To align better with user expectations, we now suggest the negative lookahead in the getting started docs and point out the case with pathnames containing dots. As an extra benefit, it makes it significantly easier to switch between routing strategies and add custom prefixes. With the new matcher in place, the middleware now also returns an `x-default` [alternate link](https://next-intl.dev/docs/routing#alternate-links-details) for non-root pathnames (previously only one for `/` was returned when using `localePrefix: 'always'`). Due to this, please update your middleware matcher as shown in the [getting started docs](https://next-intl.dev/docs/getting-started/app-router/with-i18n-routing#middleware) if you're using alternate links. **Related discussions:** - #1136 - #505 - #504
1 parent d17baf9 commit 15c826b

File tree

11 files changed

+84
-108
lines changed

11 files changed

+84
-108
lines changed

docs/src/pages/docs/getting-started/app-router/with-i18n-routing.mdx

+32-5
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ The simplest option is to add JSON files in your local project folder:
6161

6262
### `next.config.mjs` [#next-config]
6363

64-
Now, set up the plugin which creates an alias to provide a request-specific i18n configuration to Server Components—more on this in the following steps.
64+
Now, set up the plugin which creates an alias to provide a request-specific i18n configuration like your messages to Server Components—more on this in the following steps.
6565

6666
<Tabs items={['next.config.mjs', 'next.config.js']}>
6767
<Tabs.Tab>
@@ -117,8 +117,8 @@ export const routing = defineRouting({
117117
defaultLocale: 'en'
118118
});
119119

120-
// Lightweight wrappers around Next.js' navigation APIs
121-
// that will consider the routing configuration
120+
// Lightweight wrappers around Next.js' navigation
121+
// APIs that consider the routing configuration
122122
export const {Link, redirect, usePathname, useRouter, getPathname} =
123123
createNavigation(routing);
124124
```
@@ -136,11 +136,38 @@ import {routing} from './i18n/routing';
136136
export default createMiddleware(routing);
137137

138138
export const config = {
139-
// Match only internationalized pathnames
140-
matcher: ['/', '/(de|en)/:path*']
139+
// Match all pathnames except for
140+
// - … if they start with `/api`, `/trpc`, `/_next` or `/_vercel`
141+
// - … the ones containing a dot (e.g. `favicon.ico`)
142+
matcher: '/((?!api|trpc|_next|_vercel|.*\\..*).*)'
141143
};
142144
```
143145

146+
<Details id="middleware-matcher-dots">
147+
<summary>How can I match pathnames that contain dots like `/users/jane.doe`?</summary>
148+
149+
If you have pathnames where dots are expected, you can match them with explicit entries:
150+
151+
```tsx filename="src/middleware.ts" {10,11}
152+
// ...
153+
154+
export const config = {
155+
matcher: [
156+
// Match all pathnames except for
157+
// - … if they start with `/api`, `/trpc`, `/_next` or `/_vercel`
158+
// - … the ones containing a dot (e.g. `favicon.ico`)
159+
'/((?!api|trpc|_next|_vercel|.*\\..*).*)'
160+
161+
// Match all pathnames within `{/:locale}/users`
162+
'/([\\w-]+)?/users/(.+)'
163+
];
164+
}
165+
```
166+
167+
This will match e.g. `/users/jane.doe`, also optionally with a locale prefix.
168+
169+
</Details>
170+
144171
### `src/i18n/request.ts` [#i18n-request]
145172

146173
When using features from `next-intl` in Server Components, the relevant configuration is read from a central module that is located at `i18n/request.ts` by convention. This configuration is scoped to the current request and can be used to provide messages and other options based on the user's locale.

docs/src/pages/docs/routing.mdx

+5-16
Original file line numberDiff line numberDiff line change
@@ -63,13 +63,6 @@ export const routing = defineRouting({
6363
});
6464
```
6565

66-
<Details id="redirect-unprefixed-pathnames">
67-
<summary>How can I redirect unprefixed pathnames?</summary>
68-
69-
If you want to redirect unprefixed pathnames like `/about` to a prefixed alternative like `/en/about`, you can adjust your middleware matcher to [match unprefixed pathnames](/docs/routing/middleware#matcher-no-prefix) too.
70-
71-
</Details>
72-
7366
#### Don't use a locale prefix for the default locale [#locale-prefix-as-needed]
7467

7568
If you want to use no prefix for the default locale (e.g. `/about`), you can configure your routing accordingly:
@@ -83,9 +76,10 @@ export const routing = defineRouting({
8376
});
8477
```
8578

86-
**Important**: For this routing strategy to work as expected, you should additionally adapt your middleware matcher to detect [unprefixed pathnames](/docs/routing/middleware#matcher-no-prefix).
79+
**Note that:**
8780

88-
Note that if a superfluous locale prefix like `/en/about` is requested, the middleware will automatically redirect to the unprefixed version `/about`. This can be helpful in case you're redirecting from another locale and you want to update a potential cookie value first (e.g. [`<Link />`](/docs/routing/navigation#link) relies on this mechanism).
81+
1. If you use this routing strategy, make sure that your [middleware matcher](/docs/routing/middleware#matcher-config) detects unprefixed pathnames.
82+
2. If a superfluous locale prefix like `/en/about` is requested, the middleware will automatically redirect to the unprefixed version `/about`. This can be helpful in case you're redirecting from another locale and you want to update a potential cookie value first (e.g. [`<Link />`](/docs/routing/navigation#link) relies on this mechanism).
8983

9084
#### Never use a locale prefix [#locale-prefix-never]
9185

@@ -109,7 +103,7 @@ In this case, requests for all locales will be rewritten to have the locale only
109103

110104
**Note that:**
111105

112-
1. If you use this strategy, you should adapt your matcher to detect [unprefixed pathnames](/docs/routing/middleware#matcher-no-prefix).
106+
1. If you use this routing strategy, make sure that your [middleware matcher](/docs/routing/middleware#matcher-config) detects unprefixed pathnames.
113107
2. [Alternate links](#alternate-links) are disabled in this mode since URLs might not be unique per locale. Due to this, consider including these yourself, or set up a [sitemap](/docs/environments/actions-metadata-route-handlers#sitemap) that links localized pages via `alternates`.
114108
3. You can consider increasing the [`maxAge`](#locale-cookie) attribute of the locale cookie to a longer duration to remember the user's preference across sessions.
115109

@@ -561,12 +555,7 @@ link: <https://example.com/en>; rel="alternate"; hreflang="en",
561555

562556
The [`x-default`](https://developers.google.com/search/docs/specialty/international/localized-versions#xdefault) entry is included to point to a variant that can be used if no other language matches the user's browser setting. This special entry is reserved for language selection & detection, in our case issuing a 307 redirect to the best matching locale.
563557

564-
Note that middleware configuration is automatically incorporated with the following special cases:
565-
566-
1. **`localePrefix: 'always'` (default)**: The `x-default` entry is only included for `/`, not for nested pathnames like `/about`. The reason is that the default [matcher](#matcher-config) doesn't handle unprefixed pathnames apart from `/`, therefore these URLs could be 404s. Note that this only applies to the optional `x-default` entry, locale-specific URLs are always included.
567-
2. **`localePrefix: 'never'`**: Alternate links are entirely turned off since there might not be unique URLs per locale.
568-
569-
Other configuration options like `domains`, `pathnames` and `basePath` are automatically considered.
558+
Your middleware configuration, including options like `domains`, `pathnames` and `basePath`, is automatically incorporated.
570559

571560
</Details>
572561

docs/src/pages/docs/routing/middleware.mdx

+5-54
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,10 @@ import {routing} from './i18n/routing';
2323
export default createMiddleware(routing);
2424

2525
export const config = {
26-
// Match only internationalized pathnames
27-
matcher: ['/', '/(de|en)/:path*']
26+
// Match all pathnames except for
27+
// - … if they start with `/api`, `/trpc`, `/_next` or `/_vercel`
28+
// - … the ones containing a dot (e.g. `favicon.ico`)
29+
matcher: '/((?!api|trpc|_next|_vercel|.*\\..*).*)'
2830
};
2931
```
3032

@@ -106,58 +108,6 @@ The bestmatching domain is detected based on these priorities:
106108

107109
The middleware is intended to only run on pages, not on arbitrary files that you serve independently of the user locale (e.g. `/favicon.ico`).
108110

109-
Because of this, the following config is generally recommended:
110-
111-
```tsx filename="middleware.ts"
112-
export const config = {
113-
// Match only internationalized pathnames
114-
matcher: ['/', '/(de|en)/:path*']
115-
};
116-
```
117-
118-
This enables:
119-
120-
1. A redirect at `/` to a suitable locale
121-
2. Internationalization of all pathnames starting with a locale (e.g. `/en/about`)
122-
123-
<Details id="matcher-avoid-hardcoding">
124-
<summary>Can I avoid hardcoding the locales in the `matcher` config?</summary>
125-
126-
A [Next.js `matcher`](https://nextjs.org/docs/app/building-your-application/routing/middleware#matcher) needs to be statically analyzable, therefore you can't use variables to generate this value. However, you can alternatively implement a programmatic condition in the middleware:
127-
128-
```tsx filename="middleware.ts"
129-
import {NextRequest} from 'next/server';
130-
import createMiddleware from 'next-intl/middleware';
131-
import {routing} from './i18n/routing';
132-
133-
const handleI18nRouting = createMiddleware(routing);
134-
135-
export default function middleware(request: NextRequest) {
136-
const {pathname} = request.nextUrl;
137-
138-
// Matches '/', as well as all paths that start with a locale like '/en'
139-
const shouldHandle =
140-
pathname === '/' ||
141-
new RegExp(`^/(${locales.join('|')})(/.*)?$`).test(
142-
request.nextUrl.pathname
143-
);
144-
if (!shouldHandle) return;
145-
146-
return handleI18nRouting(request);
147-
}
148-
```
149-
150-
</Details>
151-
152-
### Pathnames without a locale prefix [#matcher-no-prefix]
153-
154-
There are two use cases where you might want to match pathnames without a locale prefix:
155-
156-
1. You're using a config for [`localePrefix`](/docs/routing#locale-prefix) other than [`always`](/docs/routing#locale-prefix-always)
157-
2. You want to enable redirects that add a locale for unprefixed pathnames (e.g. `/about``/en/about`)
158-
159-
For these cases, the middleware should run on requests for pathnames without a locale prefix as well.
160-
161111
A popular strategy is to match all routes that don't start with certain segments (e.g. `/_next`) and also none that include a dot (`.`) since these typically indicate static files. However, if you have some routes where a dot is expected (e.g. `/users/jane.doe`), you should explicitly provide a matcher for these.
162112

163113
```tsx filename="middleware.ts"
@@ -169,6 +119,7 @@ export const config = {
169119
// - … if they start with `/api`, `/_next` or `/_vercel`
170120
// - … the ones containing a dot (e.g. `favicon.ico`)
171121
'/((?!api|_next|_vercel|.*\\..*).*)',
122+
172123
// However, match all pathnames within `/users`, optionally with a locale prefix
173124
'/([\\w-]+)?/users/(.+)'
174125
]

examples/example-app-router-migration/src/middleware.ts

+4-2
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import {routing} from './i18n/routing';
44
export default createMiddleware(routing);
55

66
export const config = {
7-
// Skip all paths that should not be internationalized
8-
matcher: ['/((?!api|_next|_vercel|.*\\..*).*)']
7+
// Match all pathnames except for
8+
// - … if they start with `/api`, `/trpc`, `/_next` or `/_vercel`
9+
// - … the ones containing a dot (e.g. `favicon.ico`)
10+
matcher: '/((?!api|trpc|_next|_vercel|.*\\..*).*)'
911
};

examples/example-app-router-mixed-routing/src/middleware.ts

+4-2
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import {routing} from './i18n/routing.public';
44
export default createMiddleware(routing);
55

66
export const config = {
7-
// Match only public pathnames
8-
matcher: ['/', '/(de|en)/:path*']
7+
// Match all pathnames except for
8+
// - … if they start with `/app`, `/_next` or `/_vercel`
9+
// - … the ones containing a dot (e.g. `favicon.ico`)
10+
matcher: '/((?!app|_next|_vercel|.*\\..*).*)'
911
};

examples/example-app-router-mixed-routing/tests/main.spec.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {test as it, expect} from '@playwright/test';
1+
import {expect, test as it} from '@playwright/test';
22

33
it('syncs the locale across the public and private pages', async ({page}) => {
44
await page.goto('/');

examples/example-app-router-next-auth/src/middleware.ts

+4-2
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ export default function middleware(req: NextRequest) {
4343
}
4444

4545
export const config = {
46-
// Skip all paths that should not be internationalized
47-
matcher: ['/((?!api|_next|.*\\..*).*)']
46+
// Match all pathnames except for
47+
// - … if they start with `/api`, `/trpc`, `/_next` or `/_vercel`
48+
// - … the ones containing a dot (e.g. `favicon.ico`)
49+
matcher: ['/((?!api|trpc|_next|_vercel|.*\\..*).*)']
4850
};

examples/example-app-router/src/middleware.ts

+4-12
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,8 @@ import {routing} from './i18n/routing';
44
export default createMiddleware(routing);
55

66
export const config = {
7-
matcher: [
8-
// Enable a redirect to a matching locale at the root
9-
'/',
10-
11-
// Set a cookie to remember the previous locale for
12-
// all requests that have a locale prefix
13-
'/(de|en)/:path*',
14-
15-
// Enable redirects that add missing locales
16-
// (e.g. `/pathnames` -> `/en/pathnames`)
17-
'/((?!_next|_vercel|.*\\..*).*)'
18-
]
7+
// Match all pathnames except for
8+
// - … if they start with `/api`, `/trpc`, `/_next` or `/_vercel`
9+
// - … the ones containing a dot (e.g. `favicon.ico`)
10+
matcher: '/((?!api|trpc|_next|_vercel|.*\\..*).*)'
1911
};

packages/next-intl/src/middleware/getAlternateLinksHeaderValue.test.tsx

+2-1
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,8 @@ describe.each([{basePath: undefined}, {basePath: '/base'}])(
184184
}).split(', ')
185185
).toEqual([
186186
`<https://example.com${basePath}/en/about>; rel="alternate"; hreflang="en"`,
187-
`<https://example.com${basePath}/es/about>; rel="alternate"; hreflang="es"`
187+
`<https://example.com${basePath}/es/about>; rel="alternate"; hreflang="es"`,
188+
`<https://example.com${basePath}/about>; rel="alternate"; hreflang="x-default"`
188189
]);
189190
});
190191

packages/next-intl/src/middleware/getAlternateLinksHeaderValue.tsx

+1-2
Original file line numberDiff line numberDiff line change
@@ -143,8 +143,7 @@ export default function getAlternateLinksHeaderValue<
143143
// Add x-default entry
144144
const shouldAddXDefault =
145145
// For domain-based routing there is no reasonable x-default
146-
!routing.domains &&
147-
(routing.localePrefix.mode !== 'always' || normalizedUrl.pathname === '/');
146+
!routing.domains || routing.domains.length === 0;
148147
if (shouldAddXDefault) {
149148
const url = new URL(
150149
getLocalizedPathname(normalizedUrl.pathname, routing.defaultLocale),

packages/next-intl/src/middleware/middleware.test.tsx

+22-11
Original file line numberDiff line numberDiff line change
@@ -1400,35 +1400,42 @@ describe('prefix-based routing', () => {
14001400
]);
14011401
expect(getLinks(createMockRequest('/en/about', 'en'))).toEqual([
14021402
'<http://localhost:3000/en/about>; rel="alternate"; hreflang="en"',
1403-
'<http://localhost:3000/de/ueber>; rel="alternate"; hreflang="de"'
1403+
'<http://localhost:3000/de/ueber>; rel="alternate"; hreflang="de"',
1404+
'<http://localhost:3000/about>; rel="alternate"; hreflang="x-default"'
14041405
]);
14051406
expect(getLinks(createMockRequest('/de/ueber', 'de'))).toEqual([
14061407
'<http://localhost:3000/en/about>; rel="alternate"; hreflang="en"',
1407-
'<http://localhost:3000/de/ueber>; rel="alternate"; hreflang="de"'
1408+
'<http://localhost:3000/de/ueber>; rel="alternate"; hreflang="de"',
1409+
'<http://localhost:3000/about>; rel="alternate"; hreflang="x-default"'
14081410
]);
14091411
expect(getLinks(createMockRequest('/en/users/1', 'en'))).toEqual([
14101412
'<http://localhost:3000/en/users/1>; rel="alternate"; hreflang="en"',
1411-
'<http://localhost:3000/de/benutzer/1>; rel="alternate"; hreflang="de"'
1413+
'<http://localhost:3000/de/benutzer/1>; rel="alternate"; hreflang="de"',
1414+
'<http://localhost:3000/users/1>; rel="alternate"; hreflang="x-default"'
14121415
]);
14131416
expect(getLinks(createMockRequest('/de/benutzer/1', 'de'))).toEqual([
14141417
'<http://localhost:3000/en/users/1>; rel="alternate"; hreflang="en"',
1415-
'<http://localhost:3000/de/benutzer/1>; rel="alternate"; hreflang="de"'
1418+
'<http://localhost:3000/de/benutzer/1>; rel="alternate"; hreflang="de"',
1419+
'<http://localhost:3000/users/1>; rel="alternate"; hreflang="x-default"'
14161420
]);
14171421
expect(
14181422
getLinks(createMockRequest('/en/products/apparel/t-shirts', 'en'))
14191423
).toEqual([
14201424
'<http://localhost:3000/en/products/apparel/t-shirts>; rel="alternate"; hreflang="en"',
1421-
'<http://localhost:3000/de/produkte/apparel/t-shirts>; rel="alternate"; hreflang="de"'
1425+
'<http://localhost:3000/de/produkte/apparel/t-shirts>; rel="alternate"; hreflang="de"',
1426+
'<http://localhost:3000/products/apparel/t-shirts>; rel="alternate"; hreflang="x-default"'
14221427
]);
14231428
expect(
14241429
getLinks(createMockRequest('/de/produkte/apparel/t-shirts', 'de'))
14251430
).toEqual([
14261431
'<http://localhost:3000/en/products/apparel/t-shirts>; rel="alternate"; hreflang="en"',
1427-
'<http://localhost:3000/de/produkte/apparel/t-shirts>; rel="alternate"; hreflang="de"'
1432+
'<http://localhost:3000/de/produkte/apparel/t-shirts>; rel="alternate"; hreflang="de"',
1433+
'<http://localhost:3000/products/apparel/t-shirts>; rel="alternate"; hreflang="x-default"'
14281434
]);
14291435
expect(getLinks(createMockRequest('/en/unknown', 'en'))).toEqual([
14301436
'<http://localhost:3000/en/unknown>; rel="alternate"; hreflang="en"',
1431-
'<http://localhost:3000/de/unknown>; rel="alternate"; hreflang="de"'
1437+
'<http://localhost:3000/de/unknown>; rel="alternate"; hreflang="de"',
1438+
'<http://localhost:3000/unknown>; rel="alternate"; hreflang="x-default"'
14321439
]);
14331440
});
14341441

@@ -1747,15 +1754,17 @@ describe('prefix-based routing', () => {
17471754
'<http://localhost:3000/en/about>; rel="alternate"; hreflang="en"',
17481755
'<http://localhost:3000/uk/about>; rel="alternate"; hreflang="en-gb"',
17491756
'<http://localhost:3000/de/at/about>; rel="alternate"; hreflang="de-at"',
1750-
'<http://localhost:3000/br/about>; rel="alternate"; hreflang="pt"'
1757+
'<http://localhost:3000/br/about>; rel="alternate"; hreflang="pt"',
1758+
'<http://localhost:3000/about>; rel="alternate"; hreflang="x-default"'
17511759
]);
17521760
});
17531761

17541762
expect(getLinks(createMockRequest('/en/unknown'))).toEqual([
17551763
'<http://localhost:3000/en/unknown>; rel="alternate"; hreflang="en"',
17561764
'<http://localhost:3000/uk/unknown>; rel="alternate"; hreflang="en-gb"',
17571765
'<http://localhost:3000/de/at/unknown>; rel="alternate"; hreflang="de-at"',
1758-
'<http://localhost:3000/br/unknown>; rel="alternate"; hreflang="pt"'
1766+
'<http://localhost:3000/br/unknown>; rel="alternate"; hreflang="pt"',
1767+
'<http://localhost:3000/unknown>; rel="alternate"; hreflang="x-default"'
17591768
]);
17601769
});
17611770
});
@@ -1880,15 +1889,17 @@ describe('prefix-based routing', () => {
18801889
'<http://localhost:3000/en/about>; rel="alternate"; hreflang="en"',
18811890
'<http://localhost:3000/uk/about>; rel="alternate"; hreflang="en-gb"',
18821891
'<http://localhost:3000/de/at/ueber>; rel="alternate"; hreflang="de-at"',
1883-
'<http://localhost:3000/br/sobre>; rel="alternate"; hreflang="pt"'
1892+
'<http://localhost:3000/br/sobre>; rel="alternate"; hreflang="pt"',
1893+
'<http://localhost:3000/about>; rel="alternate"; hreflang="x-default"'
18841894
]);
18851895
});
18861896

18871897
expect(getLinks(createMockRequest('/en/unknown'))).toEqual([
18881898
'<http://localhost:3000/en/unknown>; rel="alternate"; hreflang="en"',
18891899
'<http://localhost:3000/uk/unknown>; rel="alternate"; hreflang="en-gb"',
18901900
'<http://localhost:3000/de/at/unknown>; rel="alternate"; hreflang="de-at"',
1891-
'<http://localhost:3000/br/unknown>; rel="alternate"; hreflang="pt"'
1901+
'<http://localhost:3000/br/unknown>; rel="alternate"; hreflang="pt"',
1902+
'<http://localhost:3000/unknown>; rel="alternate"; hreflang="x-default"'
18921903
]);
18931904
});
18941905
});

0 commit comments

Comments
 (0)