Skip to content

Commit e0a9303

Browse files
authoredOct 12, 2024··
add nextra/locales middleware which can be exported from root/middleware.{js,ts} file to detect and redirect to the user-selected language for i18n websites (#3439)
* aa * aaa * aaa * aaa * Update .changeset/large-feet-tell.md * Update docs/pages/docs/guide/i18n.mdx
1 parent dfd2dec commit e0a9303

File tree

8 files changed

+126
-8
lines changed

8 files changed

+126
-8
lines changed
 

‎.changeset/large-feet-tell.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'nextra': patch
3+
---
4+
5+
add `nextra/locales` middleware which can be exported from `root-of-your-project/middleware.{js,ts}` file to detect and redirect to the user-selected language for i18n websites

‎docs/pages/docs/guide/i18n.mdx

+25-2
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,30 @@ i18n: [
5353
]
5454
```
5555

56-
</Steps>
56+
## Automatically Detect and Redirect to User-Selected Language (Optional)
57+
58+
You can automatically detect the user's preferred language and redirect them to
59+
the corresponding version of the site. To achieve this, create a `middleware.js`
60+
file in the root of your project and export Nextra's middleware function from
61+
`nextra/locales`:
62+
63+
```js filename="middleware.js" {1}
64+
export { middleware } from 'nextra/locales'
65+
66+
export const config = {
67+
// Matcher ignoring `/_next/` and `/api/`
68+
matcher: [
69+
'/((?!api|_next/static|_next/image|favicon.ico|icon.svg|apple-icon.png|manifest).*)'
70+
]
71+
}
72+
```
73+
74+
<Callout type="warning">
75+
**Note**: This approach will not work for i18n sites that are statically
76+
exported with `output: "export"` in `nextConfig`.
77+
</Callout>
5778

58-
## Custom 404 Page
79+
## Custom 404 Page (Optional)
5980

6081
In **Nextra 3**, it's not possible to create a `404.mdx` page for an i18n
6182
website that uses a shared theme layout. However, you can implement a `404.jsx`
@@ -67,3 +88,5 @@ In **Nextra 4**, you can have a custom `not-found.jsx` with translations for an
6788
i18n website that uses a shared theme layout. For guidance on implementing this,
6889
you can check out the
6990
[SWR i18n example](https://github.com/shuding/nextra/blob/c9d0ffc8687644401412b8adc34af220cccddf82/examples/swr-site/app/%5Blang%5D/not-found.ts).
91+
92+
</Steps>

‎examples/swr-site/middleware.ts

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export { middleware } from 'nextra/locales'
2+
3+
export const config = {
4+
// Matcher ignoring `/_next/` and `/api/`
5+
matcher: [
6+
'/((?!api|_next/static|_next/image|favicon.ico|icon.svg|apple-icon.png|manifest).*)'
7+
]
8+
}

‎examples/swr-site/next.config.js

-5
Original file line numberDiff line numberDiff line change
@@ -99,11 +99,6 @@ export default withBundleAnalyzer(
9999
source: '/examples',
100100
destination: '/examples/basic',
101101
statusCode: 302
102-
},
103-
{
104-
source: '/',
105-
destination: '/en',
106-
permanent: true
107102
}
108103
],
109104
reactStrictMode: true

‎packages/nextra/package.json

+10
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@
4343
"import": "./dist/server/schemas.js",
4444
"types": "./dist/server/schemas.d.ts"
4545
},
46+
"./locales": {
47+
"import": "./dist/server/locales.js",
48+
"types": "./dist/server/locales.d.ts"
49+
},
4650
"./fetch-filepaths-from-github": {
4751
"import": "./dist/server/fetch-filepaths-from-github.js",
4852
"types": "./dist/server/fetch-filepaths-from-github.d.ts"
@@ -74,6 +78,9 @@
7478
"schemas": [
7579
"./dist/server/schemas.d.ts"
7680
],
81+
"locales": [
82+
"./dist/server/locales.d.ts"
83+
],
7784
"catch-all": [
7885
"./dist/client/catch-all.d.ts"
7986
],
@@ -120,6 +127,7 @@
120127
"react-dom": ">=18"
121128
},
122129
"dependencies": {
130+
"@formatjs/intl-localematcher": "^0.5.4",
123131
"@headlessui/react": "^2.1.2",
124132
"@mdx-js/mdx": "^3.0.0",
125133
"@mdx-js/react": "^3.0.0",
@@ -136,6 +144,7 @@
136144
"gray-matter": "^4.0.3",
137145
"hast-util-to-estree": "^3.1.0",
138146
"katex": "^0.16.9",
147+
"negotiator": "^0.6.3",
139148
"p-limit": "^6.0.0",
140149
"rehype-katex": "^7.0.0",
141150
"rehype-pretty-code": "0.14.0",
@@ -161,6 +170,7 @@
161170
"@types/graceful-fs": "^4.1.9",
162171
"@types/hast": "^3.0.4",
163172
"@types/mdast": "^4.0.4",
173+
"@types/negotiator": "^0.6.3",
164174
"@types/react": "^18.3.3",
165175
"@types/webpack": "^5.28.5",
166176
"@vitejs/plugin-react": "^4.3.1",

‎packages/nextra/src/server/index.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,8 @@ const nextra: Nextra = nextraConfig => {
8888
...nextConfig.env,
8989
...(hasI18n && {
9090
NEXTRA_DEFAULT_LOCALE:
91-
nextConfig.i18n?.defaultLocale || DEFAULT_LOCALE
91+
nextConfig.i18n?.defaultLocale || DEFAULT_LOCALE,
92+
NEXTRA_LOCALES: JSON.stringify(nextConfig.i18n?.locales)
9293
}),
9394
NEXTRA_SEARCH: String(!!loaderOptions.search)
9495
},

‎packages/nextra/src/server/locales.ts

+50
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { match as matchLocale } from '@formatjs/intl-localematcher'
2+
import Negotiator from 'negotiator'
3+
import { NextResponse } from 'next/server'
4+
import type { NextRequest } from 'next/server'
5+
6+
const locales = JSON.parse(process.env.NEXTRA_LOCALES!) as string[]
7+
8+
const defaultLocale = process.env.NEXTRA_DEFAULT_LOCALE!
9+
10+
const HAS_LOCALE_RE = new RegExp(`^\\/(${locales.join('|')})(\\/|$)`)
11+
12+
const COOKIE_NAME = 'NEXT_LOCALE'
13+
14+
function getHeadersLocale(request: NextRequest): string {
15+
const headers = Object.fromEntries(
16+
// @ts-expect-error -- this works
17+
request.headers.entries()
18+
)
19+
20+
// Use negotiator and intl-localematcher to get best locale
21+
const languages = new Negotiator({ headers }).languages(locales)
22+
const locale = matchLocale(languages, locales, defaultLocale)
23+
24+
return locale
25+
}
26+
27+
export function middleware(request: NextRequest) {
28+
const { pathname } = request.nextUrl
29+
30+
// Check if there is any supported locale in the pathname
31+
const pathnameHasLocale = HAS_LOCALE_RE.test(pathname)
32+
const cookieLocale = request.cookies.get(COOKIE_NAME)?.value
33+
34+
// Redirect if there is no locale
35+
if (!pathnameHasLocale) {
36+
const locale = cookieLocale || getHeadersLocale(request)
37+
38+
// e.g. incoming request is /products
39+
// The new URL is now /en-US/products
40+
return NextResponse.redirect(new URL(`/${locale}${pathname}`, request.url))
41+
}
42+
43+
const requestLocale = pathname.split('/', 2)[1]
44+
45+
if (requestLocale !== cookieLocale) {
46+
const response = NextResponse.next()
47+
response.cookies.set(COOKIE_NAME, requestLocale)
48+
return response
49+
}
50+
}

‎pnpm-lock.yaml

+26
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)
Please sign in to comment.