Skip to content

Commit 0e984bd

Browse files
authored
feat: Add localeCookie option for middleware (#1414)
1 parent a95bd86 commit 0e984bd

File tree

8 files changed

+112
-11
lines changed

8 files changed

+112
-11
lines changed

docs/pages/docs/routing/middleware.mdx

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ export const config = {
2828
};
2929
```
3030

31-
## Locale detection
31+
## Locale detection [#locale-detection-overview]
3232

3333
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.
3434

@@ -122,6 +122,44 @@ In this case, only the locale prefix and a potentially [matching domain](#domain
122122

123123
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)).
124124

125+
### Locale cookie [#locale-cookie]
126+
127+
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 preference for future requests (see [locale detection](#locale-detection-overview)).
128+
129+
By default, the cookie will be configured with the following attributes:
130+
131+
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.
132+
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.
133+
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.
134+
135+
If you have more specific requirements, you can adjust these settings accordingly:
136+
137+
```tsx filename="middleware.ts"
138+
import createMiddleware from 'next-intl/middleware';
139+
import {routing} from './i18n/routing';
140+
141+
export default createMiddleware(routing, {
142+
// These options will be merged with the defaults
143+
localeCookie: {
144+
// Expire in one day
145+
maxAge: 60 * 60 * 24
146+
}
147+
});
148+
```
149+
150+
… or turn the cookie off entirely:
151+
152+
```tsx filename="middleware.ts"
153+
import createMiddleware from 'next-intl/middleware';
154+
import {routing} from './i18n/routing';
155+
156+
export default createMiddleware(routing, {
157+
localeCookie: false
158+
});
159+
```
160+
161+
Note that the cookie is only set once per detected locale and is not updated on every request.
162+
125163
### Alternate links [#alternate-links]
126164

127165
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.

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import createMiddleware from 'next-intl/middleware';
22
import {routing} from './i18n/routing';
33

4-
export default createMiddleware(routing);
4+
export default createMiddleware(routing, {
5+
localeCookie: {
6+
// 200 days
7+
maxAge: 200 * 24 * 60 * 60
8+
}
9+
});
510

611
export const config = {
712
matcher: [

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -682,6 +682,11 @@ it('can use `getPahname` to define a canonical link', async ({page}) => {
682682
await expect(getCanonicalPathname()).resolves.toBe('/de/neuigkeiten/3');
683683
});
684684

685+
it('can define custom cookie options', async ({request}) => {
686+
const response = await request.get('/');
687+
expect(response.headers()['set-cookie']).toContain('Max-Age=17280000');
688+
});
689+
685690
it('can use `t.has` in a Server Component', async ({page}) => {
686691
await page.goto('/');
687692
await expect(page.getByTestId('HasTitle')).toHaveText('true');

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ const config: SizeLimitConfig = [
6060
{
6161
name: 'import createMiddleware from \'next-intl/middleware\'',
6262
path: 'dist/production/middleware.js',
63-
limit: '9.63 KB'
63+
limit: '9.675 KB'
6464
},
6565
{
6666
name: 'import * from \'next-intl/routing\'',
@@ -71,7 +71,7 @@ const config: SizeLimitConfig = [
7171
name: 'import * from \'next-intl\' (react-client, ESM)',
7272
path: 'dist/esm/index.react-client.js',
7373
import: '*',
74-
limit: '14.265 kB'
74+
limit: '14.245 kB'
7575
},
7676
{
7777
name: 'import {NextIntlProvider} from \'next-intl\' (react-client, ESM)',

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

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,38 @@
1+
import {NextResponse} from 'next/server';
2+
3+
type ResponseCookieOptions = Pick<
4+
NonNullable<Parameters<typeof NextResponse.prototype.cookies.set>['2']>,
5+
| 'maxAge'
6+
| 'domain'
7+
| 'expires'
8+
| 'partitioned'
9+
| 'path'
10+
| 'priority'
11+
| 'sameSite'
12+
| 'secure'
13+
// Not:
14+
// - 'httpOnly' (the client side needs to read the cookie)
15+
// - 'name' (the client side needs to know this as well)
16+
// - 'value' (only the middleware knows this)
17+
>;
18+
119
export type MiddlewareOptions = {
2-
/** Sets the `Link` response header to notify search engines about content in other languages (defaults to `true`). See https://developers.google.com/search/docs/specialty/international/localized-versions#http */
20+
/**
21+
* Sets the `Link` response header to notify search engines about content in other languages (defaults to `true`). See https://developers.google.com/search/docs/specialty/international/localized-versions#http
22+
* @see https://next-intl-docs.vercel.app/docs/routing/middleware#alternate-links
23+
**/
324
alternateLinks?: boolean;
425

5-
/** By setting this to `false`, the cookie as well as the `accept-language` header will no longer be used for locale detection. */
26+
/**
27+
* Can be used to disable the locale cookie or to customize it.
28+
* @see https://next-intl-docs.vercel.app/docs/routing/middleware#locale-cookie
29+
*/
30+
localeCookie?: boolean | ResponseCookieOptions;
31+
32+
/**
33+
* By setting this to `false`, the cookie as well as the `accept-language` header will no longer be used for locale detection.
34+
* @see https://next-intl-docs.vercel.app/docs/routing/middleware#locale-detection
35+
**/
636
localeDetection?: boolean;
737
};
838

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,24 @@ describe('prefix-based routing', () => {
294294
});
295295
});
296296

297+
it('can turn off the cookie', () => {
298+
const response = createMiddleware(routing, {localeCookie: false})(
299+
createMockRequest('/')
300+
);
301+
expect(response.cookies.get('NEXT_LOCALE')).toBeUndefined();
302+
});
303+
304+
it('restricts which options of the cookie can be customized', () => {
305+
createMiddleware(routing, {
306+
localeCookie: {
307+
// @ts-expect-error
308+
httpOnly: true,
309+
name: 'custom',
310+
value: 'custom'
311+
}
312+
});
313+
});
314+
297315
it('retains request headers for the default locale', () => {
298316
middleware(
299317
createMockRequest('/', 'en', 'http://localhost:3000', undefined, {

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,8 @@ export default function createMiddleware<
4949
const resolvedOptions = {
5050
alternateLinks: options?.alternateLinks ?? routing.alternateLinks ?? true,
5151
localeDetection:
52-
options?.localeDetection ?? routing?.localeDetection ?? true
52+
options?.localeDetection ?? routing?.localeDetection ?? true,
53+
localeCookie: options?.localeCookie ?? routing?.localeCookie ?? true
5354
};
5455

5556
return function middleware(request: NextRequest) {
@@ -311,8 +312,8 @@ export default function createMiddleware<
311312
}
312313
}
313314

314-
if (resolvedOptions.localeDetection) {
315-
syncCookie(request, response, locale);
315+
if (resolvedOptions.localeDetection && resolvedOptions.localeCookie) {
316+
syncCookie(request, response, locale, resolvedOptions.localeCookie);
316317
}
317318

318319
if (

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

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,23 @@ import {
44
COOKIE_MAX_AGE,
55
COOKIE_SAME_SITE
66
} from '../shared/constants';
7+
import {MiddlewareOptions} from './config';
78

89
export default function syncCookie(
910
request: NextRequest,
1011
response: NextResponse,
11-
locale: string
12+
locale: string,
13+
localeCookie: MiddlewareOptions['localeCookie']
1214
) {
1315
const hasOutdatedCookie =
1416
request.cookies.get(COOKIE_LOCALE_NAME)?.value !== locale;
17+
1518
if (hasOutdatedCookie) {
1619
response.cookies.set(COOKIE_LOCALE_NAME, locale, {
1720
path: request.nextUrl.basePath || undefined,
1821
sameSite: COOKIE_SAME_SITE,
19-
maxAge: COOKIE_MAX_AGE
22+
maxAge: COOKIE_MAX_AGE,
23+
...(typeof localeCookie === 'object' && localeCookie)
2024
});
2125
}
2226
}

0 commit comments

Comments
 (0)