Skip to content

Commit 021e874

Browse files
authored
feat!: Stricter config for domains to improve handling of localePrefix: 'as-needed' (#1734)
So far, when using [`domains`](https://next-intl.dev/docs/routing#domains) in combination with `localePrefix: 'as-needed'`, `next-intl` had to make some [tradeoffs](https://next-intl-docs-6wwcmwb9a-next-intl.vercel.app/docs/routing#domains-localeprefix-asneeded). Now, `next-intl` comes with stricter requirements when `domains` is used: 1. A locale can now only be used for a single domain 2. Each domain now must specify its `locales` By introducing these constraints, the mentioned tradeoffs now can be removed altogether, resulting in a simplified model. If you previously used locales across multiple domains, you now have to be more specific—typically by introducing a regional variant for a base language. You can additionally customize the prefixes if desired. **Before** ```tsx import {defineRouting} from 'next-intl/routing'; export const routing = defineRouting({ locales: ['sv', 'en', 'no', 'fr'], defaultLocale: 'en', localePrefix: 'as-needed', domains: [ { domain: 'domain.se', defaultLocale: 'sv', locales: ['sv', 'en'] }, { domain: 'domain.no', defaultLocale: 'no', locales: ['no', 'en'] }, { domain: 'domain.com', defaultLocale: 'en', locales: ['en', 'fr'] }, ] }); ``` **After** ```tsx import {defineRouting} from 'next-intl/routing'; export const routing = defineRouting({ locales: ['sv-SE', 'en-SE', 'no-NO', 'en-NO', 'fr-FR', 'en-US'], defaultLocale: 'en-US', localePrefix: { mode: 'as-needed', prefixes: { 'en-SE': '/en', 'en-NO': '/en', 'en-US': '/en', 'fr-FR': '/fr' } }, domains: [ { domain: 'domain.se', defaultLocale: 'sv-SE', locales: ['sv-SE', 'en-SE'] }, { domain: 'domain.no', defaultLocale: 'no-NO', locales: ['no-NO', 'en-NO'] }, { domain: 'domain.com', defaultLocale: 'en-US', locales: ['en-US', 'fr-FR'] }, ] }); ``` Learn more in the updated docs for [`domains`](https://v4.next-intl.dev/docs/routing#domains). Resolves #1733 **TODO** - [x] Docs - [x] Implementation - [x] Real world test and make sure we don't have any problems with prefixes - [ ] When users have more locales, #990 would be really handy - [ ] Back-port warning to v3 (also that locales are becoming unique per domain) - [ ] Update blog post
1 parent 4106641 commit 021e874

21 files changed

+328
-353
lines changed

docs/src/pages/docs/routing.mdx

+30-54
Original file line numberDiff line numberDiff line change
@@ -321,37 +321,49 @@ If you want to serve your localized content based on different domains, you can
321321

322322
**Examples:**
323323

324-
- `us.example.com/en`
325-
- `ca.example.com/en`
326-
- `ca.example.com/fr`
324+
- `us.example.com`: `en-US`
325+
- `ca.example.com`: `en-CA`
326+
- `ca.example.com/fr`: `fr-CA`
327+
- `fr.example.com`: `fr-FR`
328+
329+
In many cases, `domains` are combined with a [`localePrefix`](#locale-prefix) setting to achieve results as shown above. Also [custom prefixes](#locale-prefix-custom) can be used to customize the user-facing prefix per locale.
327330

328331
```tsx filename="routing.ts"
329332
import {defineRouting} from 'next-intl/routing';
330333

331334
export const routing = defineRouting({
332-
locales: ['en', 'fr'],
333-
defaultLocale: 'en',
335+
locales: ['en-US', 'en-CA', 'fr-CA', 'fr-FR'],
336+
defaultLocale: 'en-US',
334337
domains: [
335338
{
336339
domain: 'us.example.com',
337-
defaultLocale: 'en',
338-
// Optionally restrict the locales available on this domain
339-
locales: ['en']
340+
defaultLocale: 'en-US',
341+
locales: ['en-US']
340342
},
341343
{
342344
domain: 'ca.example.com',
343-
defaultLocale: 'en'
344-
// If there are no `locales` specified on a domain,
345-
// all available locales will be supported here
345+
defaultLocale: 'en-CA',
346+
locales: ['en-CA', 'fr-CA']
347+
},
348+
{
349+
domain: 'fr.example.com',
350+
defaultLocale: 'fr-FR',
351+
locales: ['fr-FR']
346352
}
347-
]
353+
],
354+
localePrefix: {
355+
mode: 'as-needed',
356+
prefixes: {
357+
// Cleaner prefix for `ca.example.com/fr`
358+
'fr-CA': '/fr'
359+
}
360+
}
348361
});
349362
```
350363

351-
**Note that:**
364+
Locales are required to be unique across domains, therefore regional variants are typically used to avoid conflicts. Note however that you don't necessarily need to [provide messages for each locale](/docs/usage/configuration#messages-per-locale) if the overall language is sufficient for your use case.
352365

353-
1. You can optionally remove the locale prefix in pathnames by changing the [`localePrefix`](#locale-prefix) setting. E.g. [`localePrefix: 'never'`](#locale-prefix-never) can be helpful in case you have unique domains per locale.
354-
2. If no domain matches, the middleware will fall back to the [`defaultLocale`](/docs/routing/middleware#default-locale) (e.g. on `localhost`).
366+
If no domain matches, the middleware will fall back to the general [`defaultLocale`](/docs/routing/middleware#default-locale) (e.g. on `localhost`).
355367

356368
<Details id="domains-testing">
357369
<summary>How can I locally test if my setup is working?</summary>
@@ -393,9 +405,7 @@ PORT=3001 npm run dev
393405
<Details id="domains-localeprefix-individual">
394406
<summary>Can I use a different `localePrefix` setting per domain?</summary>
395407

396-
Since such a configuration would require reading the domain at runtime, this would prevent the ability to render pages statically. Due to this, `next-intl` doesn't support this configuration out of the box.
397-
398-
However, you can still achieve this by building the app for each domain separately, while injecting diverging routing configuration via an environment variable.
408+
While this is currently not supported out of the box, you can still achieve this by building the app for each domain separately while injecting diverging routing configuration via an environment variable.
399409

400410
**Example:**
401411

@@ -406,48 +416,14 @@ const isUsDomain =
406416
process.env.VERCEL_PROJECT_PRODUCTION_URL === 'us.example.com';
407417

408418
export const routing = defineRouting({
409-
locales: isUsDomain ? ['en'] : ['en', 'fr'],
410-
defaultLocale: 'en',
419+
locales: isUsDomain ? ['en-US'] : ['en-CA', 'fr-CA'],
420+
defaultLocale: isUsDomain ? 'en-US' : 'en-CA',
411421
localePrefix: isUsDomain ? 'never' : 'always'
412422
});
413423
```
414424

415425
</Details>
416426

417-
<Details id="domains-localeprefix-asneeded">
418-
<summary>Special case: Using `domains` with `localePrefix: 'as-needed'`</summary>
419-
420-
Since domains can have different default locales, this combination requires some tradeoffs that apply to the [navigation APIs](/docs/routing/navigation) in order for `next-intl` to avoid reading the current host on the server side (which would prevent the usage of static rendering).
421-
422-
1. [`<Link />`](/docs/routing/navigation#link): This component will always render a locale prefix on the server side, even for the default locale of a given domain. However, during hydration on the client side, the prefix is potentially removed, if the default locale of the current domain is used. Note that the temporarily prefixed pathname will always be valid, however the middleware will potentially clean up a superfluous prefix via a redirect if the user clicks on a link before hydration.
423-
2. [`redirect`](/docs/routing/navigation#redirect): When calling this function, a locale prefix is always added, regardless of the provided locale. However, similar to the handling with `<Link />`, the middleware will potentially clean up a superfluous prefix.
424-
3. [`getPathname`](/docs/routing/navigation#getpathname): This function requires that a `domain` is passed as part of the arguments in order to avoid ambiguity. This can either be provided statically (e.g. when used in a sitemap), or read from a header like [`x-forwarded-host`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-Host).
425-
426-
```tsx
427-
import {getPathname} from '@/i18n/routing';
428-
import {headers} from 'next/headers';
429-
430-
// Case 1: Statically known domain
431-
const domain = 'ca.example.com';
432-
433-
// Case 2: Read at runtime (dynamic rendering)
434-
const domain = headers().get('x-forwarded-host');
435-
436-
// Assuming the current domain is `ca.example.com`,
437-
// the returned pathname will be `/about`
438-
const pathname = getPathname({
439-
href: '/about',
440-
locale: 'en',
441-
domain
442-
});
443-
```
444-
445-
A `domain` can optionally also be passed to `redirect` in the same manner to ensure that a prefix is only added when necessary. Alternatively, you can also consider redirecting in the middleware or via [`useRouter`](/docs/routing/navigation#usrouter) on the client side.
446-
447-
If you need to avoid these tradeoffs, you can consider building the same app for each domain separately, while injecting diverging routing configuration via an [environment variable](#domains-localeprefix-individual).
448-
449-
</Details>
450-
451427
### Turning off locale detection [#locale-detection]
452428

453429
The middleware will [detect a matching locale](/docs/routing/middleware#locale-detection) based on your routing configuration and the incoming request.

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

+3-4
Original file line numberDiff line numberDiff line change
@@ -93,14 +93,13 @@ Since the middleware is aware of all your domains, if a domain receives a reques
9393
4. The middleware recognizes that the user wants to switch to another domain and responds with a redirect to `ca.example.com/fr`.
9494

9595
<Details id="domain-matching">
96-
<summary>How is the best matching domain for a given locale detected?</summary>
96+
<summary>How is the best-matching domain for a given locale detected?</summary>
9797

98-
The bestmatching domain is detected based on these priorities:
98+
The best-matching domain is detected based on these priorities:
9999

100100
1. Stay on the current domain if the locale is supported here
101101
2. Use an alternative domain where the locale is configured as the `defaultLocale`
102-
3. Use an alternative domain where the available `locales` are restricted and the locale is supported
103-
4. Use an alternative domain that supports all locales
102+
3. Use an alternative domain that supports the locale
104103

105104
</Details>
106105

docs/src/pages/docs/usage/configuration.mdx

+24
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,30 @@ Note that [the VSCode integration for `next-intl`](/docs/workflows/vscode-integr
358358

359359
</Details>
360360

361+
<Details id="messages-per-locale">
362+
<summary>Do I need separate messages for each locale that my app supports?</summary>
363+
364+
Since you have full control over how messages are loaded, you can choose to load messages for example merely based on the overall language, ignoring any regional variants:
365+
366+
```tsx
367+
import {getRequestConfig} from 'next-intl/server';
368+
369+
export default getRequestConfig(async () => {
370+
// E.g. "en-US", "en-CA", …
371+
const locale = 'en-US';
372+
373+
// E.g. "en"
374+
const language = new Intl.Locale(locale).language;
375+
376+
// Load messages based on the language
377+
const messages = (await import(`../../messages/${language}.json`)).default;
378+
379+
// ...
380+
});
381+
```
382+
383+
</Details>
384+
361385
### `useMessages` & `getMessages` [#use-messages]
362386

363387
In case you require access to messages in a component, you can read them via `useMessages()` or `getMessages()` from your configuration:

examples/example-app-router-playground/src/i18n/routing.ts

+4-2
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,13 @@ export const routing = defineRouting({
1818
? [
1919
{
2020
domain: 'example.com',
21-
defaultLocale: 'en'
21+
defaultLocale: 'en',
22+
locales: ['en', 'es', 'ja']
2223
},
2324
{
2425
domain: 'example.de',
25-
defaultLocale: 'de'
26+
defaultLocale: 'de',
27+
locales: ['de']
2628
}
2729
]
2830
: undefined,

examples/example-app-router-playground/tests/domains.spec.ts

+1-2
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,5 @@ it('can use a secondary locale unprefixed if the domain has specified it as the
3737
await page.getByRole('link', {name: 'Start'}).click();
3838
await expect(page).toHaveURL('http://example.de');
3939
await page.getByRole('link', {name: 'Zu Englisch wechseln'}).click();
40-
await expect(page).toHaveURL('http://example.de/en');
41-
await expect(page.getByRole('heading', {name: 'Home'})).toBeVisible();
40+
await expect(page).toHaveURL('http://example.com/en');
4241
});

packages/next-intl/.size-limit.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,13 @@ const config: SizeLimitConfig = [
2121
name: "import {createNavigation} from 'next-intl/navigation' (react-client)",
2222
path: 'dist/esm/production/navigation.react-client.js',
2323
import: '{createNavigation}',
24-
limit: '2.485 KB'
24+
limit: '2.285 KB'
2525
},
2626
{
2727
name: "import {createNavigation} from 'next-intl/navigation' (react-server)",
2828
path: 'dist/esm/production/navigation.react-server.js',
2929
import: '{createNavigation}',
30-
limit: '3.275 KB'
30+
limit: '3.055 KB'
3131
},
3232
{
3333
name: "import * from 'next-intl/server' (react-client)",
@@ -42,7 +42,7 @@ const config: SizeLimitConfig = [
4242
{
4343
name: "import * from 'next-intl/middleware'",
4444
path: 'dist/esm/production/middleware.js',
45-
limit: '9.315 KB'
45+
limit: '9.355 KB'
4646
},
4747
{
4848
name: "import * from 'next-intl/routing'",

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

+4-4
Original file line numberDiff line numberDiff line change
@@ -197,8 +197,8 @@ describe.each([{basePath: undefined}, {basePath: '/base'}])(
197197
domains: [
198198
{
199199
domain: 'example.com',
200-
defaultLocale: 'en'
201-
// (supports all locales)
200+
defaultLocale: 'en',
201+
locales: ['en', 'es', 'fr']
202202
},
203203
{
204204
domain: 'example.es',
@@ -264,8 +264,8 @@ describe.each([{basePath: undefined}, {basePath: '/base'}])(
264264
domains: [
265265
{
266266
domain: 'example.com',
267-
defaultLocale: 'en'
268-
// (supports all locales)
267+
defaultLocale: 'en',
268+
locales: ['en', 'es', 'fr']
269269
},
270270
{
271271
domain: 'example.es',

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

+77-14
Original file line numberDiff line numberDiff line change
@@ -2480,7 +2480,8 @@ describe('domain-based routing', () => {
24802480
domains: [
24812481
{
24822482
defaultLocale: 'fr',
2483-
domain: 'ca.example.com'
2483+
domain: 'ca.example.com',
2484+
locales: ['en', 'fr']
24842485
}
24852486
]
24862487
});
@@ -3193,20 +3194,73 @@ describe('domain-based routing', () => {
31933194
describe('custom prefixes with pathnames', () => {
31943195
const middlewareWithPrefixes = createMiddleware({
31953196
defaultLocale: 'en',
3196-
locales: ['en', 'en-gb'],
3197+
locales: ['en', 'en-gb', 'sv-SE', 'en-SE', 'no-NO', 'en-NO'],
31973198
localePrefix: {
31983199
mode: 'as-needed',
31993200
prefixes: {
3200-
'en-gb': '/uk'
3201+
'en-gb': '/uk',
3202+
'en-SE': '/en',
3203+
'en-NO': '/en'
32013204
}
32023205
},
32033206
pathnames: {
32043207
'/': '/',
32053208
'/about': {
32063209
en: '/about',
3207-
'en-gb': '/about'
3210+
'en-gb': '/about',
3211+
'en-SE': '/about',
3212+
'en-NO': '/about',
3213+
'sv-SE': '/about',
3214+
'no-NO': '/about'
3215+
}
3216+
} satisfies Pathnames<
3217+
ReadonlyArray<'en' | 'en-gb' | 'sv-SE' | 'en-SE' | 'no-NO' | 'en-NO'>
3218+
>,
3219+
domains: [
3220+
{
3221+
defaultLocale: 'en-gb',
3222+
domain: 'example.co.uk',
3223+
locales: ['en-gb']
3224+
},
3225+
{
3226+
defaultLocale: 'sv-SE',
3227+
domain: 'example.se',
3228+
locales: ['sv-SE', 'en-SE']
3229+
},
3230+
{
3231+
defaultLocale: 'no-NO',
3232+
domain: 'example.no',
3233+
locales: ['no-NO', 'en-NO']
3234+
},
3235+
{
3236+
defaultLocale: 'en',
3237+
domain: 'example.com',
3238+
locales: ['en']
32083239
}
3209-
} satisfies Pathnames<ReadonlyArray<'en' | 'en-gb'>>
3240+
]
3241+
});
3242+
3243+
it('serves requests for overlapping prefixes', () => {
3244+
middlewareWithPrefixes(
3245+
createMockRequest('/', undefined, 'http://example.com')
3246+
);
3247+
middlewareWithPrefixes(
3248+
createMockRequest('/en', undefined, 'http://example.no')
3249+
);
3250+
middlewareWithPrefixes(
3251+
createMockRequest('/en', undefined, 'http://example.se')
3252+
);
3253+
expect(MockedNextResponse.redirect).not.toHaveBeenCalled();
3254+
expect(MockedNextResponse.rewrite).toHaveBeenCalledTimes(3);
3255+
expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe(
3256+
'http://example.com/en'
3257+
);
3258+
expect(MockedNextResponse.rewrite.mock.calls[1][0].toString()).toBe(
3259+
'http://example.no/en-NO'
3260+
);
3261+
expect(MockedNextResponse.rewrite.mock.calls[2][0].toString()).toBe(
3262+
'http://example.se/en-SE'
3263+
);
32103264
});
32113265

32123266
it('serves requests for the default locale at the root', () => {
@@ -3256,24 +3310,33 @@ describe('domain-based routing', () => {
32563310

32573311
['/', '/uk'].forEach((pathname) => {
32583312
expect(getLinks(createMockRequest(pathname))).toEqual([
3259-
'<http://localhost:3000/>; rel="alternate"; hreflang="en"',
3260-
'<http://localhost:3000/uk>; rel="alternate"; hreflang="en-gb"',
3261-
'<http://localhost:3000/>; rel="alternate"; hreflang="x-default"'
3313+
'<http://example.com/>; rel="alternate"; hreflang="en"',
3314+
'<http://example.co.uk/>; rel="alternate"; hreflang="en-gb"',
3315+
'<http://example.se/>; rel="alternate"; hreflang="sv-SE"',
3316+
'<http://example.se/en>; rel="alternate"; hreflang="en-SE"',
3317+
'<http://example.no/>; rel="alternate"; hreflang="no-NO"',
3318+
'<http://example.no/en>; rel="alternate"; hreflang="en-NO"'
32623319
]);
32633320
});
32643321

32653322
['/about', '/uk/about'].forEach((pathname) => {
32663323
expect(getLinks(createMockRequest(pathname))).toEqual([
3267-
'<http://localhost:3000/about>; rel="alternate"; hreflang="en"',
3268-
'<http://localhost:3000/uk/about>; rel="alternate"; hreflang="en-gb"',
3269-
'<http://localhost:3000/about>; rel="alternate"; hreflang="x-default"'
3324+
'<http://example.com/about>; rel="alternate"; hreflang="en"',
3325+
'<http://example.co.uk/about>; rel="alternate"; hreflang="en-gb"',
3326+
'<http://example.se/about>; rel="alternate"; hreflang="sv-SE"',
3327+
'<http://example.se/en/about>; rel="alternate"; hreflang="en-SE"',
3328+
'<http://example.no/about>; rel="alternate"; hreflang="no-NO"',
3329+
'<http://example.no/en/about>; rel="alternate"; hreflang="en-NO"'
32703330
]);
32713331
});
32723332

32733333
expect(getLinks(createMockRequest('/unknown'))).toEqual([
3274-
'<http://localhost:3000/unknown>; rel="alternate"; hreflang="en"',
3275-
'<http://localhost:3000/uk/unknown>; rel="alternate"; hreflang="en-gb"',
3276-
'<http://localhost:3000/unknown>; rel="alternate"; hreflang="x-default"'
3334+
'<http://example.com/unknown>; rel="alternate"; hreflang="en"',
3335+
'<http://example.co.uk/unknown>; rel="alternate"; hreflang="en-gb"',
3336+
'<http://example.se/unknown>; rel="alternate"; hreflang="sv-SE"',
3337+
'<http://example.se/en/unknown>; rel="alternate"; hreflang="en-SE"',
3338+
'<http://example.no/unknown>; rel="alternate"; hreflang="no-NO"',
3339+
'<http://example.no/en/unknown>; rel="alternate"; hreflang="en-NO"'
32773340
]);
32783341
});
32793342
});

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

+2-1
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,8 @@ export default function createMiddleware<
152152
const pathnameMatch = getPathnameMatch(
153153
externalPathname,
154154
resolvedRouting.locales,
155-
resolvedRouting.localePrefix
155+
resolvedRouting.localePrefix,
156+
domain
156157
);
157158
const hasLocalePrefix = pathnameMatch != null;
158159

0 commit comments

Comments
 (0)