Skip to content

Commit

Permalink
fix: When using domain-based routing, use defaultLocale of a domain…
Browse files Browse the repository at this point in the history
… instead of the top-level one in case no other locale matches better on the domain (#1000)

Fixes #998 

Note that the `defaultLocale` of a domain is used if no other locale
matches better. However, if a domain supports multiple locales, the
best-matching one will be selected based on the `accept-language`
header. If you want to always use the `defaultLocale` in case no prefix
is provided, then you can turn off `localeDetection`.
  • Loading branch information
amannn authored Apr 17, 2024
1 parent 672eccf commit 42988b7
Show file tree
Hide file tree
Showing 4 changed files with 109 additions and 53 deletions.
15 changes: 7 additions & 8 deletions docs/pages/docs/routing/middleware.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -84,16 +84,14 @@ export default createMiddleware({
{
domain: 'us.example.com',
defaultLocale: 'en',
// Optionally restrict the locales managed by this domain. If this
// domain receives requests for another locale (e.g. us.example.com/fr),
// then the middleware will redirect to a domain that supports it.
// Optionally restrict the locales available on this domain
locales: ['en']
},
{
domain: 'ca.example.com',
defaultLocale: 'en'
// If there are no `locales` specified on a domain,
// all global locales will be supported here.
// all available locales will be supported here
}
]
});
Expand All @@ -110,11 +108,12 @@ To match the request against the available domains, the host is read from the `x

The locale is detected based on these priorities:

1. A locale prefix is present in the pathname and the domain supports it (e.g. `ca.example.com/fr`)
2. If the host of the request is configured in `domains`, the `defaultLocale` of the domain is used
3. As a fallback, the [locale detection of prefix-based routing](#locale-detection) applies
1. A locale prefix is present in the pathname (e.g. `ca.example.com/fr`)
2. A locale is stored in a cookie and is supported on the domain
3. A locale that the domain supports is matched based on the [`accept-language` header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Language)
4. As a fallback, the `defaultLocale` of the domain is used

Since the middleware is aware of all your domains, the domain will automatically be switched when the user requests to change the locale.
Since the middleware is aware of all your domains, if a domain receives a request for a locale that is not supported (e.g. `en.example.com/fr`), it will redirect to an alternative domain that does support the locale.

**Example workflow:**

Expand Down
2 changes: 1 addition & 1 deletion packages/next-intl/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@
},
{
"path": "dist/production/middleware.js",
"limit": "5.95 KB"
"limit": "6 KB"
}
]
}
126 changes: 83 additions & 43 deletions packages/next-intl/src/middleware/resolveLocale.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,12 +55,35 @@ export function getAcceptLanguageLocale<Locales extends AllLocales>(
return locale;
}

function getLocaleFromPrefix<Locales extends AllLocales>(
pathname: string,
locales: Locales
) {
const pathLocaleCandidate = getFirstPathnameSegment(pathname);
return findCaseInsensitiveLocale(pathLocaleCandidate, locales);
}

function getLocaleFromCookie<Locales extends AllLocales>(
requestCookies: RequestCookies,
locales: Locales
) {
if (requestCookies.has(COOKIE_LOCALE_NAME)) {
const value = requestCookies.get(COOKIE_LOCALE_NAME)?.value;
if (value && locales.includes(value)) {
return value;
}
}
}

function resolveLocaleFromPrefix<Locales extends AllLocales>(
{
defaultLocale,
localeDetection,
locales
}: MiddlewareConfigWithDefaults<Locales>,
}: Pick<
MiddlewareConfigWithDefaults<Locales>,
'defaultLocale' | 'localeDetection' | 'locales'
>,
requestHeaders: Headers,
requestCookies: RequestCookies,
pathname: string
Expand All @@ -69,24 +92,12 @@ function resolveLocaleFromPrefix<Locales extends AllLocales>(

// Prio 1: Use route prefix
if (pathname) {
const pathLocaleCandidate = getFirstPathnameSegment(pathname);
const matchedLocale = findCaseInsensitiveLocale(
pathLocaleCandidate,
locales
);
if (matchedLocale) {
locale = matchedLocale;
}
locale = getLocaleFromPrefix(pathname, locales);
}

// Prio 2: Use existing cookie
if (!locale && localeDetection && requestCookies) {
if (requestCookies.has(COOKIE_LOCALE_NAME)) {
const value = requestCookies.get(COOKIE_LOCALE_NAME)?.value;
if (value && locales.includes(value)) {
locale = value;
}
}
locale = getLocaleFromCookie(requestCookies, locales);
}

// Prio 3: Use the `accept-language` header
Expand All @@ -108,37 +119,66 @@ function resolveLocaleFromDomain<Locales extends AllLocales>(
requestCookies: RequestCookies,
pathname: string
) {
const {domains} = config;

const localeFromPrefixStrategy = resolveLocaleFromPrefix(
config,
requestHeaders,
requestCookies,
pathname
);

// Prio 1: Use a domain
if (domains) {
const domain = findDomainFromHost(requestHeaders, domains);
const hasLocalePrefix =
pathname && pathname.startsWith(`/${localeFromPrefixStrategy}`);

if (domain) {
return {
locale:
isLocaleSupportedOnDomain<Locales>(
localeFromPrefixStrategy,
domain
) || hasLocalePrefix
? localeFromPrefixStrategy
: domain.defaultLocale,
domain
};
const domains = config.domains!;
const domain = findDomainFromHost(requestHeaders, domains);

if (!domain) {
return {
locale: resolveLocaleFromPrefix(
config,
requestHeaders,
requestCookies,
pathname
)
};
}

let locale;

// Prio 1: Use route prefix
if (pathname) {
const prefixLocale = getLocaleFromPrefix(pathname, config.locales);
if (prefixLocale) {
if (isLocaleSupportedOnDomain(prefixLocale, domain)) {
locale = prefixLocale;
} else {
// Causes a redirect to a domain that supports the locale
return {locale: prefixLocale, domain};
}
}
}

// Prio 2: Use existing cookie
if (!locale && config.localeDetection && requestCookies) {
const cookieLocale = getLocaleFromCookie(requestCookies, config.locales);
if (cookieLocale) {
if (isLocaleSupportedOnDomain(cookieLocale, domain)) {
locale = cookieLocale;
} else {
// Ignore
}
}
}

// Prio 2: Use prefix strategy
return {locale: localeFromPrefixStrategy};
// Prio 3: Use the `accept-language` header
if (!locale && config.localeDetection && requestHeaders) {
const headerLocale = getAcceptLanguageLocale(
requestHeaders,
domain.locales || config.locales,
domain.defaultLocale
);

if (headerLocale) {
locale = headerLocale;
}
}

// Prio 4: Use default locale
if (!locale) {
locale = domain.defaultLocale;
}

return {locale, domain};
}

export default function resolveLocale<Locales extends AllLocales>(
Expand Down
19 changes: 18 additions & 1 deletion packages/next-intl/test/middleware/middleware.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ function createMockRequest(
customHeaders?: HeadersInit
) {
const headers = new Headers({
'accept-language': `${acceptLanguageLocale};q=0.9,en;q=0.8`,
'accept-language': `${acceptLanguageLocale};q=0.9`,
host: new URL(host).host,
...(localeCookieValue && {
cookie: `${COOKIE_LOCALE_NAME}=${localeCookieValue}`
Expand Down Expand Up @@ -1765,6 +1765,23 @@ describe('domain-based routing', () => {
);
});

it('prioritizes the default locale of a domain', () => {
const m = createIntlMiddleware({
defaultLocale: 'en',
locales: ['en', 'fr'],
domains: [
{
defaultLocale: 'fr',
domain: 'ca.example.com'
}
]
});
m(createMockRequest('/', 'de', 'http://ca.example.com'));
expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe(
'http://ca.example.com/fr'
);
});

describe('unknown hosts', () => {
it('serves requests for unknown hosts at the root', () => {
middleware(createMockRequest('/', 'en', 'http://localhost'));
Expand Down

0 comments on commit 42988b7

Please sign in to comment.