Skip to content

Commit

Permalink
fix: Remove x-default alternate links entry for non-root pathnames …
Browse files Browse the repository at this point in the history
…when using `localePrefix: 'always'` (#805)

Closes #799

**Before:**

```
link: <https://example.com/en/about>; rel="alternate"; hreflang="en",
      <https://example.com/de/about>; rel="alternate"; hreflang="de",
      <https://example.com/about>; rel="alternate"; hreflang="x-default"
```

**After:**

```
link: <https://example.com/en/about>; rel="alternate"; hreflang="en",
      <https://example.com/de/about>; rel="alternate"; hreflang="de"
```

**Notes:**
- Only non-root pathnames are affected, the root (`/`) will still
include the `x-default` entry.
- The `x-default` entry is considered optional and we're still providing
all links to localized pages, therefore this is fine from an SEO
perspective.
  • Loading branch information
amannn authored Jan 23, 2024
1 parent 716d716 commit c5bb0f5
Show file tree
Hide file tree
Showing 5 changed files with 48 additions and 33 deletions.
47 changes: 34 additions & 13 deletions docs/pages/docs/routing/middleware.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export const config = {
};
```

In addition to handling i18n routing, the middleware sets [the `link` header](https://developers.google.com/search/docs/specialty/international/localized-versions#http) to inform search engines that your content is available in different languages.
In addition to handling i18n routing, the middleware sets the `link` header to inform search engines that your content is available in different languages (see [alternate links](#alternate-links)).

## Strategies

Expand All @@ -36,7 +36,7 @@ Once a locale is detected, it will be saved in the `NEXT_LOCALE` cookie.

Since your pages are nested within a `[locale]` folder, all routes are by default prefixed with one of your supported locales (e.g. `/en/about`).

#### Locale detection
#### Locale detection [#prefix-locale-detection]

The locale is detected based on these priorities:

Expand Down Expand Up @@ -104,7 +104,7 @@ export default createMiddleware({
[`localePrefix`](#locale-prefix) setting.
</Callout>

#### Locale detection
#### Locale detection [#domain-locale-detection]

To match the request against the available domains, the host is read from the `x-forwarded-host` header, with a fallback to `host`.

Expand Down Expand Up @@ -206,9 +206,9 @@ In this case, requests for all locales will be rewritten to have the locale only

1. If you use this strategy, you should make sure that [your matcher detects unprefixed pathnames](#matcher-no-prefix).
2. If you don't use domain-based routing, the cookie is now the source of truth for determining the locale in the middleware. Make sure that your hosting solution reliably returns the `set-cookie` header from the middleware (e.g. Vercel and Cloudflare are known to potentially [strip this header](https://developers.cloudflare.com/cache/concepts/cache-behavior/#interaction-of-set-cookie-response-header-with-cache) for cacheable requests).
3. [Alternate links](#disable-alternate-links) are disabled in this mode since there might not be distinct URLs per locale.
3. [Alternate links](#alternate-links) are disabled in this mode since there might not be distinct URLs per locale.

### Disable locale detection [#disable-locale-detection]
### Locale detection [#locale-detection]

If you want to rely entirely on the URL to resolve the locale, you can disable locale detection based on the `accept-language` header and a potentially existing cookie value from a previous visit.

Expand All @@ -226,7 +226,7 @@ In this case, only the locale prefix and a potentially [matching domain](#domain

Note that by setting this option, the middleware will no longer return a `set-cookie` response header, which can be beneficial for CDN caching (see e.g. [the Cloudflare Cache rules for `set-cookie`](https://developers.cloudflare.com/cache/concepts/cache-behavior/#interaction-of-set-cookie-response-header-with-cache)).

### Disable alternate links
### Alternate links [#alternate-links]

The middleware automatically sets [the `link` header](https://developers.google.com/search/docs/specialty/international/localized-versions#http) to inform search engines that your content is available in different languages. Note that this automatically integrates with your routing strategy and will generate the correct links based on your configuration.

Expand All @@ -243,9 +243,29 @@ export default createMiddleware({
```

<details>
<summary>Can I customize the alternate links?</summary>
<summary>Which alternate links are included?</summary>

Using the middleware defaults, the `link` header of a response for `/` will look like this:

```
link: <https://example.com/en>; rel="alternate"; hreflang="en",
<https://example.com/de>; rel="alternate"; hreflang="de",
<https://example.com/>; rel="alternate"; hreflang="x-default"
```

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.

Note that middleware configuration is automatically incorporated with the following special cases:

The alternate links can either be turned off or on, depending on the `alternateLinks` option.
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.
2. **`localePrefix: 'never'`**: Alternate links are entirely turned off since there might not be unique URLs per locale.

Other configuration options like `domains`, `pathnames` and `basePath` are automatically considered.

</details>

<details>
<summary>Can I customize the alternate links?</summary>

If you need to customize the alternate links, you can either turn them off and provide your own implementation, or if you only need to make minor adaptions, you can [compose the middleware](#composing-other-middlewares) and add your custom logic after the middleware has run:

Expand Down Expand Up @@ -375,22 +395,23 @@ Note that some third-party providers like [Vercel Analytics](https://vercel.com/

The `next-intl` middleware as well as [the navigation APIs](/docs/routing/navigation) will automatically pick up a [`basePath`](https://nextjs.org/docs/app/api-reference/next-config-js/basePath) that you might have configured in your `next.config.js`.

Note however that you should make sure that your [middleware `matcher`](#matcher-config) matches the root of your base path:
Note however that you should make sure that your [middleware `matcher`](#matcher-config) handles the root of your base path:

```tsx filename="middleware.ts"
// ...

export const config = {
// The `matcher` is relative to the `basePath`
matcher: [
'/' // Make sure the root is matched
// This entry handles the root of the base
// path and should always be included
'/'

// ... other matcher config
]
};
```

See also: [`vercel/next.js#47085`](https://github.com/vercel/next.js/issues/47085)

## Composing other middlewares

By calling `createMiddleware`, you'll receive a function of the following type:
Expand Down Expand Up @@ -592,7 +613,7 @@ If you're using the [static export feature from Next.js](https://nextjs.org/docs
**Static export limitations:**

1. There's no default locale that can be used without a prefix (same as [`localePrefix: 'always'`](#locale-prefix-always))
2. The locale can't be negotiated at runtime (same as [`localeDetection: false`](#disable-locale-detection))
2. The locale can't be negotiated at runtime (same as [`localeDetection: false`](#locale-detection))
3. You can't use [pathname localization](#localizing-pathnames)
4. This requires [static rendering](/docs/getting-started/app-router#static-rendering)
5. You need to add a redirect for the root of the app
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.8 KB"
"limit": "5.81 KB"
}
]
}
Original file line number Diff line number Diff line change
Expand Up @@ -114,14 +114,16 @@ export default function getAlternateLinksHeaderValue<
});

// Add x-default entry
if (!config.domains) {
const shouldAddXDefault =
// For domain-based routing there is no reasonable x-default
!config.domains &&
(config.localePrefix !== 'always' || normalizedUrl.pathname === '/');
if (shouldAddXDefault) {
const url = new URL(
getLocalizedPathname(normalizedUrl.pathname, config.defaultLocale),
normalizedUrl
);
links.push(getAlternateEntry(url, 'x-default'));
} else {
// For domain-based routing there is no reasonable x-default
}

return links.join(', ');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -190,8 +190,7 @@ describe.each([{basePath: undefined}, {basePath: '/base'}])(
}).split(', ')
).toEqual([
`<https://example.com${basePath}/en/about>; rel="alternate"; hreflang="en"`,
`<https://example.com${basePath}/es/about>; rel="alternate"; hreflang="es"`,
`<https://example.com${basePath}/about>; rel="alternate"; hreflang="x-default"`
`<https://example.com${basePath}/es/about>; rel="alternate"; hreflang="es"`
]);
});

Expand Down
21 changes: 7 additions & 14 deletions packages/next-intl/test/middleware/middleware.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -903,42 +903,35 @@ describe('prefix-based routing', () => {
]);
expect(getLinks(createMockRequest('/en/about', 'en'))).toEqual([
'<http://localhost:3000/en/about>; rel="alternate"; hreflang="en"',
'<http://localhost:3000/de/ueber>; rel="alternate"; hreflang="de"',
'<http://localhost:3000/about>; rel="alternate"; hreflang="x-default"'
'<http://localhost:3000/de/ueber>; rel="alternate"; hreflang="de"'
]);
expect(getLinks(createMockRequest('/de/ueber', 'de'))).toEqual([
'<http://localhost:3000/en/about>; rel="alternate"; hreflang="en"',
'<http://localhost:3000/de/ueber>; rel="alternate"; hreflang="de"',
'<http://localhost:3000/about>; rel="alternate"; hreflang="x-default"'
'<http://localhost:3000/de/ueber>; rel="alternate"; hreflang="de"'
]);
expect(getLinks(createMockRequest('/en/users/1', 'en'))).toEqual([
'<http://localhost:3000/en/users/1>; rel="alternate"; hreflang="en"',
'<http://localhost:3000/de/benutzer/1>; rel="alternate"; hreflang="de"',
'<http://localhost:3000/users/1>; rel="alternate"; hreflang="x-default"'
'<http://localhost:3000/de/benutzer/1>; rel="alternate"; hreflang="de"'
]);
expect(getLinks(createMockRequest('/de/benutzer/1', 'de'))).toEqual([
'<http://localhost:3000/en/users/1>; rel="alternate"; hreflang="en"',
'<http://localhost:3000/de/benutzer/1>; rel="alternate"; hreflang="de"',
'<http://localhost:3000/users/1>; rel="alternate"; hreflang="x-default"'
'<http://localhost:3000/de/benutzer/1>; rel="alternate"; hreflang="de"'
]);
expect(
getLinks(createMockRequest('/en/products/apparel/t-shirts', 'en'))
).toEqual([
'<http://localhost:3000/en/products/apparel/t-shirts>; rel="alternate"; hreflang="en"',
'<http://localhost:3000/de/produkte/apparel/t-shirts>; rel="alternate"; hreflang="de"',
'<http://localhost:3000/products/apparel/t-shirts>; rel="alternate"; hreflang="x-default"'
'<http://localhost:3000/de/produkte/apparel/t-shirts>; rel="alternate"; hreflang="de"'
]);
expect(
getLinks(createMockRequest('/de/produkte/apparel/t-shirts', 'de'))
).toEqual([
'<http://localhost:3000/en/products/apparel/t-shirts>; rel="alternate"; hreflang="en"',
'<http://localhost:3000/de/produkte/apparel/t-shirts>; rel="alternate"; hreflang="de"',
'<http://localhost:3000/products/apparel/t-shirts>; rel="alternate"; hreflang="x-default"'
'<http://localhost:3000/de/produkte/apparel/t-shirts>; rel="alternate"; hreflang="de"'
]);
expect(getLinks(createMockRequest('/en/unknown', 'en'))).toEqual([
'<http://localhost:3000/en/unknown>; rel="alternate"; hreflang="en"',
'<http://localhost:3000/de/unknown>; rel="alternate"; hreflang="de"',
'<http://localhost:3000/unknown>; rel="alternate"; hreflang="x-default"'
'<http://localhost:3000/de/unknown>; rel="alternate"; hreflang="de"'
]);
});
});
Expand Down

2 comments on commit c5bb0f5

@vercel
Copy link

@vercel vercel bot commented on c5bb0f5 Jan 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

next-intl-docs – ./docs

next-intl-docs-git-main-next-intl.vercel.app
next-intl-docs.vercel.app
next-intl-docs-next-intl.vercel.app

@vercel
Copy link

@vercel vercel bot commented on c5bb0f5 Jan 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.