Skip to content

Commit

Permalink
feat!: Decrease cookie expiration to 5 hours, only set cookie when ne…
Browse files Browse the repository at this point in the history
…cessary and only disable cookie if `localeCookie: false` is set (#1487)

**Changes**
1. The `maxAge` attribute of the locale cookie is decreased from 1 year
to 5 hours in order to be GDPR-compliant.
2. The locale cookie is now only set when the user's language doesn't
match a requested locale. E.g. a user with `accept-language: 'en'` will
cause a cookie to be set when `/de` is requested to remember the
preference for `de`.
3. `localeDetection: false` previously ambiguously also disabled the
cookie from being set. This is no longer the case. For consistency, you
can now use the explicit [`localeCookie:
false`](https://next-intl-docs.vercel.app/docs/routing#locale-cookie)
option instead.

If you want to increase the cookie expiration, you can use the
[`maxAge`](https://next-intl-docs.vercel.app/docs/routing#locale-cookie)
attribute to do so:

```tsx
import {defineRouting} from 'next-intl/routing';
 
export const routing = defineRouting({
  // ...
 
  localeCookie: {
    // Expire in one year
    maxAge: 60 * 60 * 24 * 365
  }
});
```
  • Loading branch information
amannn authored Oct 30, 2024
1 parent 5402851 commit b4e4d1d
Show file tree
Hide file tree
Showing 15 changed files with 179 additions and 83 deletions.
25 changes: 18 additions & 7 deletions docs/src/pages/docs/routing.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,8 @@ In this case, requests for all locales will be rewritten to have the locale only
**Note that:**

1. If you use this strategy, you should adapt your matcher to detect [unprefixed pathnames](/docs/routing/middleware#matcher-no-prefix).
2. If you don't use domain-based routing, the cookie is now the source of truth for determining the locale. 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](#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`.
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`.
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.

#### Custom prefixes [#locale-prefix-custom]

Expand Down Expand Up @@ -472,11 +472,11 @@ In this case, only the locale prefix and a potentially [matching domain](#domain

### Locale cookie [#locale-cookie]

By default, the middleware will set a cookie called `NEXT_LOCALE` that contains the most recently detected locale. This is used to [remember the user's locale](/docs/routing/middleware#locale-detection) preference for future requests.
If a user changes the locale to a value that doesn't match the `accept-language` header, `next-intl` will set a cookie called `NEXT_LOCALE` that contains the most recently detected locale. This is used to [remember the user's locale](/docs/routing/middleware#locale-detection) preference for future requests.

By default, the cookie will be configured with the following attributes:

1. [**`maxAge`**](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#max-agenumber): This value is set to 1 year so that the preference of the user is kept as long as possible.
1. [**`maxAge`**](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#max-agenumber): This value is set to 5 hours in order to be [GDPR-compliant](#locale-cookie-gdpr) out of the box.
2. [**`sameSite`**](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value): This value is set to `lax` so that the cookie can be set when coming from an external site.
3. [**`path`**](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#pathpath-value): This value is not set by default, but will use the value of your [`basePath`](#base-path) if configured.

Expand All @@ -492,8 +492,8 @@ export const routing = defineRouting({
localeCookie: {
// Custom cookie name
name: 'USER_LOCALE',
// Expire in one day
maxAge: 60 * 60 * 24
// Expire in one year
maxAge: 60 * 60 * 24 * 365
}
});
```
Expand All @@ -510,7 +510,18 @@ export const routing = defineRouting({
});
```

Note that the cookie is only set when the user switches the locale and is not updated on every request.
<Details id="locale-cookie-gdpr">
<summary>Which `maxAge` value should I consider for GDPR compliance?</summary>

The [Article 29 Working Party opinion 04/2012](https://ec.europa.eu/justice/article-29/documentation/opinion-recommendation/files/2012/wp194_en.pdf) provides a guideline for the expiration of cookies that are used to remember the user's language in section 3.6 "UI customization cookies".

In this policy, a language preference cookie set as a result of an explicit user action, such as using a language switcher, is allowed to remain active for "a few additional hours" after a browser session has ended. To be compliant out of the box, `next-intl` sets the `maxAge` value of the cookie to 5 hours.

However, the Working Party also states that if additional information about the use of cookies is provided in a prominent location (e.g. a "uses cookies" notice next to the language switcher), the cookie can be configured to remember the user's preference for "a longer duration". If you're providing such a notice, you can consider increasing `maxAge` accordingly.

Please note that legal requirements may vary by region, so it's advisable to verify them independently. While we strive to keep this information as up-to-date as possible, we cannot guarantee its accuracy.

</Details>

### Alternate links [#alternate-links]

Expand Down
7 changes: 4 additions & 3 deletions docs/src/pages/docs/routing/middleware.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export const config = {

## Locale detection [#locale-detection]

The locale is negotiated based on your [`localePrefix`](/docs/routing#locale-prefix) and [`domains`](/docs/routing#domains) setting. Once a locale is detected, it will be remembered for future requests by being stored in the `NEXT_LOCALE` cookie.
The locale is negotiated based on your routing configuration, taking into account your settings for [`localePrefix`](/docs/routing#locale-prefix), [`domains`](/docs/routing#domains), [`localeDetection`](/docs/routing#locale-detection), and [`localeCookie`](/docs/routing#locale-cookie).

### Prefix-based routing (default) [#location-detection-prefix]

Expand All @@ -48,10 +48,11 @@ To change the locale, users can visit a prefixed route. This will take precedenc
**Example workflow:**

1. A user requests `/` and based on the `accept-language` header, the `en` locale is matched.
2. The `en` locale is saved in a cookie and the user is redirected to `/en`.
2. The user is redirected to `/en`.
3. The app renders `<Link locale="de" href="/">Switch to German</Link>` to allow the user to change the locale to `de`.
4. When the user clicks on the link, a request to `/de` is initiated.
5. The middleware will update the cookie value to `de`.
5. The middleware will add a cookie to remember the preference for the `de` locale.
6. The user later requests `/` again and the middleware will redirect to `/de` based on the cookie.

<Details id="accept-language-matching">
<summary>Which algorithm is used to match the accept-language header against the available locales?</summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import {test as it, expect} from '@playwright/test';
import {expect, test as it} from '@playwright/test';
import {assertLocaleCookieValue} from './utils';

it('updates the cookie correctly', async ({page}) => {
await page.goto('/base/path');
await assertLocaleCookieValue(page, 'en', {path: '/base/path'});
await assertLocaleCookieValue(page, undefined);

await page.getByRole('button', {name: 'Go to nested page'}).click();
await expect(page).toHaveURL('/base/path/nested');
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {test as it, expect, chromium} from '@playwright/test';
import {chromium, expect, test as it} from '@playwright/test';

it('can use config based on the default locale on an unknown domain', async ({
page
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {test as it, expect} from '@playwright/test';
import {expect, test as it} from '@playwright/test';

it('never sets a cookie', async ({page}) => {
async function expectNoCookie() {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {test as it, expect} from '@playwright/test';
import {expect, test as it} from '@playwright/test';
import {assertLocaleCookieValue} from './utils';

it('clears the router cache when changing the locale', async ({page}) => {
await page.goto('/');
Expand All @@ -7,13 +8,6 @@ it('clears the router cache when changing the locale', async ({page}) => {
await page.locator(`html[lang="${lang}"]`).waitFor();
}

async function assertCookie(locale: string) {
const cookies = await page.context().cookies();
expect(cookies.find((cookie) => cookie.name === 'NEXT_LOCALE')?.value).toBe(
locale
);
}

await expectDocumentLang('en');

await page.getByRole('link', {name: 'Client page'}).click();
Expand All @@ -22,22 +16,22 @@ it('clears the router cache when changing the locale', async ({page}) => {
await expect(
page.getByText('This page hydrates on the client side.')
).toBeAttached();
await assertCookie('en');
await assertLocaleCookieValue(page, undefined);

await page.getByRole('link', {name: 'Go to home'}).click();
await expectDocumentLang('en');
await expect(page).toHaveURL('/');
await assertCookie('en');
await assertLocaleCookieValue(page, undefined);

await page.getByRole('link', {name: 'Switch to German'}).click();
await expectDocumentLang('de');
await assertCookie('de');
await assertLocaleCookieValue(page, 'de');

await page.getByRole('link', {name: 'Client-Seite'}).click();
await expectDocumentLang('de');
await expect(page).toHaveURL('/client');
await expect(
page.getByText('Dise Seite wird auf der Client-Seite initialisiert.')
).toBeAttached();
await assertCookie('de');
await assertLocaleCookieValue(page, 'de');
});
22 changes: 15 additions & 7 deletions examples/example-app-router-playground/tests/main.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {test as it, expect, BrowserContext} from '@playwright/test';
import {getAlternateLinks, assertLocaleCookieValue} from './utils';
import {BrowserContext, expect, test as it} from '@playwright/test';
import {assertLocaleCookieValue, getAlternateLinks} from './utils';

const describe = it.describe;

Expand Down Expand Up @@ -300,17 +300,25 @@ it('keeps the locale cookie updated when changing the locale and uses soft navig
const tracker = getPageLoadTracker(context);

await page.goto('/');
await assertLocaleCookieValue(page, 'en');
await assertLocaleCookieValue(page, undefined);
expect(tracker.numPageLoads).toBe(1);

const link = page.getByRole('link', {name: 'Switch to German'});
await link.hover();
await assertLocaleCookieValue(page, 'en');
await link.click();
const linkDe = page.getByRole('link', {name: 'Switch to German'});
await linkDe.hover();
await assertLocaleCookieValue(page, undefined);
await linkDe.click();

await expect(page).toHaveURL('/de');
await assertLocaleCookieValue(page, 'de');

const linkEn = page.getByRole('link', {name: 'Zu Englisch wechseln'});
await linkEn.hover();
await assertLocaleCookieValue(page, 'de');
await linkEn.click();

await expect(page).toHaveURL('/');
await assertLocaleCookieValue(page, 'en');

// Currently, a root layout outside of the `[locale]`
// folder is required for this to work.
expect(tracker.numPageLoads).toBe(1);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {test as it, expect} from '@playwright/test';
import {expect, test as it} from '@playwright/test';
import {getAlternateLinks} from './utils';

it('redirects to a locale prefix correctly', async ({request}) => {
Expand Down
18 changes: 11 additions & 7 deletions examples/example-app-router-playground/tests/utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {APIResponse, expect, Page} from '@playwright/test';
import {APIResponse, Page, expect} from '@playwright/test';

export async function getAlternateLinks(response: APIResponse) {
return (
Expand All @@ -14,15 +14,19 @@ export async function getAlternateLinks(response: APIResponse) {

export async function assertLocaleCookieValue(
page: Page,
value: string,
value?: string,
otherProps?: Record<string, unknown>
) {
const cookie = (await page.context().cookies()).find(
(cur) => cur.name === 'NEXT_LOCALE'
);
expect(cookie).toMatchObject({
name: 'NEXT_LOCALE',
value,
...otherProps
});
if (value) {
expect(cookie).toMatchObject({
name: 'NEXT_LOCALE',
value,
...otherProps
});
} else {
expect(cookie).toBeUndefined();
}
}
24 changes: 15 additions & 9 deletions examples/example-app-router/tests/main.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {test as it, expect} from '@playwright/test';
import {expect, test as it} from '@playwright/test';

it('handles i18n routing', async ({page}) => {
await page.goto('/');
Expand Down Expand Up @@ -58,19 +58,13 @@ it('can be used to localize the page', async ({page}) => {
page.getByRole('heading', {name: 'next-intl Beispiel'});
});

it('sets a cookie', async ({page}) => {
it('sets a cookie when necessary', async ({page}) => {
function getCookieValue() {
return page.evaluate(() => document.cookie);
}

const response = await page.goto('/en');
const value = await response?.headerValue('set-cookie');
expect(value).toContain('NEXT_LOCALE=en;');
expect(value).toContain('Path=/;');
expect(value).toContain('SameSite=lax');
expect(value).toContain('Max-Age=31536000;');
expect(value).toContain('Expires=');
expect(await getCookieValue()).toBe('NEXT_LOCALE=en');
expect(await response?.headerValue('set-cookie')).toBe(null);

await page
.getByRole('combobox', {name: 'Change language'})
Expand All @@ -93,6 +87,18 @@ it('sets a cookie', async ({page}) => {
expect(await getCookieValue()).toBe('NEXT_LOCALE=de');
});

it("sets a cookie when requesting a locale that doesn't match the `accept-language` header", async ({
page
}) => {
const response = await page.goto('/de');
const value = await response?.headerValue('set-cookie');
expect(value).toContain('NEXT_LOCALE=de;');
expect(value).toContain('Path=/;');
expect(value).toContain('SameSite=lax');
expect(value).toContain('Max-Age=18000;');
expect(value).toContain('Expires=');
});

it('serves a robots.txt', async ({page}) => {
const response = await page.goto('/robots.txt');
const body = await response?.body();
Expand Down
Loading

0 comments on commit b4e4d1d

Please sign in to comment.