feat: implement browser language detection and auto-redirect for homepage#30
feat: implement browser language detection and auto-redirect for homepage#30
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
- Add custom middleware for automatic language detection - Map Chinese language codes (zh, zh-CN, zh-TW, zh-HK) to 'cn' - Redirect non-default language users to localized paths (/cn/) - Keep default language (en) users on clean URLs (/) - Store language preference in cookies for consistency - Add negotiator and type definitions as dependencies Co-authored-by: huangyiirene <7665279+huangyiirene@users.noreply.github.com>
- Remove unused @formatjs/intl-localematcher dependency - Extract SUPPORTED_LANGUAGES constant to reduce type assertion duplication - Use Object.fromEntries for more concise header conversion - Improve code readability and efficiency Co-authored-by: huangyiirene <7665279+huangyiirene@users.noreply.github.com>
- Add setLocaleCookie helper function with consistent cookie options - Set sameSite=lax and path=/ for security and consistency - Update comment for SUPPORTED_LANGUAGES constant - Ensure all cookie operations use the same configuration Co-authored-by: huangyiirene <7665279+huangyiirene@users.noreply.github.com>
- Handle root path (/) specially to avoid double slashes - Use precise regex for locale prefix removal - Ensure clean URLs: /cn instead of /cn/ - Improve path normalization logic Co-authored-by: huangyiirene <7665279+huangyiirene@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR implements browser language detection and automatic redirection for the homepage, replacing the default fumadocs i18n middleware with a custom implementation that maps Chinese language codes to /cn while keeping English users on / through internal rewrites.
Changes:
- Custom middleware with Accept-Language header parsing using the
negotiatorlibrary - Cookie-based language preference persistence with
FD_LOCALEcookie - Locale-specific redirection logic: Chinese variants (
zh,zh-CN,zh-TW,zh-HK) redirect to/cn, English stays on/with internal rewrite to/en
Reviewed changes
Copilot reviewed 2 out of 3 changed files in this pull request and generated 10 comments.
| File | Description |
|---|---|
| apps/docs/middleware.ts | Replaced fumadocs default middleware with custom implementation for language detection, normalization, and routing |
| apps/docs/package.json | Added negotiator and @types/negotiator dependencies for Accept-Language parsing |
| pnpm-lock.yaml | Updated lockfile with new negotiator package and its type definitions |
Files not reviewed (1)
- pnpm-lock.yaml: Language not supported
| function getPreferredLanguage(request: NextRequest): string { | ||
| // Check cookie first | ||
| const cookieLocale = request.cookies.get(LOCALE_COOKIE)?.value; | ||
| if (cookieLocale && SUPPORTED_LANGUAGES.includes(cookieLocale)) { | ||
| return cookieLocale; | ||
| } | ||
|
|
||
| // Then check Accept-Language header | ||
| const negotiatorHeaders = Object.fromEntries(request.headers.entries()); | ||
| const negotiator = new Negotiator({ headers: negotiatorHeaders }); |
There was a problem hiding this comment.
The middleware creates a new Negotiator instance on every request even when a cookie is already set and valid. Consider skipping the Negotiator initialization if the cookie check at lines 56-59 returns early, by restructuring the code to only create the Negotiator instance after the cookie check fails.
|
|
||
| if (pathnameHasLocale) { | ||
| // Extract the locale from the pathname | ||
| const locale = pathname.split('/')[1]; |
There was a problem hiding this comment.
The middleware doesn't handle the case where pathname.split('/')[1] could be an empty string. If a pathname like //something is provided, locale would be an empty string, which could cause unexpected behavior. Consider adding validation to ensure the extracted locale is a non-empty string before using it.
| const locale = pathname.split('/')[1]; | |
| const locale = pathname.split('/')[1] || ''; | |
| // If the extracted locale segment is empty or invalid, skip locale-specific handling | |
| if (!locale) { | |
| return NextResponse.next(); | |
| } |
| * - Detects the user's preferred language from browser settings or cookies | ||
| * - Redirects users to the appropriate localized version | ||
| * - For default language (en): keeps URL as "/" (with internal rewrite) | ||
| * - For other languages (cn): redirects to "/cn/" |
There was a problem hiding this comment.
The comment says "redirects to '/cn/'" (with trailing slash) but the actual implementation redirects to '/cn' (without trailing slash). The documentation should be corrected to accurately reflect the implementation.
| * - For other languages (cn): redirects to "/cn/" | |
| * - For other languages (cn): redirects to "/cn" |
| function setLocaleCookie(response: NextResponse, locale: string): void { | ||
| response.cookies.set(LOCALE_COOKIE, locale, { | ||
| sameSite: 'lax', | ||
| path: '/', |
There was a problem hiding this comment.
The cookie is being set without an expiration time. This means it will be a session cookie that gets deleted when the browser closes. For a language preference, it would be better to set an explicit expiration (e.g., 1 year) so the preference persists across browser sessions.
| path: '/', | |
| path: '/', | |
| // Persist language preference across browser sessions (1 year) | |
| maxAge: 60 * 60 * 24 * 365, |
| return response; | ||
| } | ||
|
|
||
| return NextResponse.next(); |
There was a problem hiding this comment.
When the pathname has a locale and it's not the default locale, the cookie is not being set. This means if a user manually navigates to a non-default locale URL (e.g., /cn), their preference won't be stored in the cookie. Consider adding a response with the cookie set in the else branch at line 111.
| return NextResponse.next(); | |
| const response = NextResponse.next(); | |
| setLocaleCookie(response, locale); | |
| return response; |
| // Remove locale prefix more precisely to avoid issues with partial matches | ||
| url.pathname = pathname.replace(new RegExp(`^/${i18n.defaultLanguage}(/|$)`), '$1') || '/'; |
There was a problem hiding this comment.
The regular expression ^/${i18n.defaultLanguage}(/|$) could be vulnerable to ReDoS (Regular Expression Denial of Service) if i18n.defaultLanguage contains special regex characters. While the current value 'en' is safe, it would be safer to escape the language code using a helper function or use a simpler string-based approach. Consider using pathname.slice(i18n.defaultLanguage.length + 1) or escaping the language code.
| // Remove locale prefix more precisely to avoid issues with partial matches | |
| url.pathname = pathname.replace(new RegExp(`^/${i18n.defaultLanguage}(/|$)`), '$1') || '/'; | |
| // Remove default locale prefix using string operations to avoid regex issues | |
| const defaultPrefix = `/${i18n.defaultLanguage}`; | |
| if (pathname === defaultPrefix) { | |
| url.pathname = '/'; | |
| } else if (pathname.startsWith(`${defaultPrefix}/`)) { | |
| url.pathname = pathname.slice(defaultPrefix.length) || '/'; | |
| } else { | |
| // Fallback: if for some reason the pathname doesn't match the expected pattern, keep it as is | |
| url.pathname = pathname || '/'; | |
| } |
| const url = new URL(request.url); | ||
| // Handle root path specially to avoid double slashes | ||
| url.pathname = pathname === '/' ? `/${i18n.defaultLanguage}` : `/${i18n.defaultLanguage}${pathname}`; | ||
| return NextResponse.rewrite(url); |
There was a problem hiding this comment.
The cookie is not being set when the user's preferred language matches the default language. This means if an English-speaking user visits the site, their preference won't be stored, and the language detection will run on every subsequent request instead of using the cached cookie value. Consider setting the cookie even for the default language to improve performance on subsequent requests.
| return NextResponse.rewrite(url); | |
| const response = NextResponse.rewrite(url); | |
| setLocaleCookie(response, preferredLanguage); | |
| return response; |
| "@types/react": "^19.2.8", | ||
| "@types/react-dom": "^19.2.3", | ||
| "autoprefixer": "^10.4.23", | ||
| "negotiator": "^1.0.0", |
There was a problem hiding this comment.
The negotiator package is listed as a devDependency but it's imported and used in the middleware which runs at runtime. This package should be moved to the dependencies section instead of devDependencies to ensure it's available in production builds.
| const negotiatorHeaders = Object.fromEntries(request.headers.entries()); | ||
| const negotiator = new Negotiator({ headers: negotiatorHeaders }); |
There was a problem hiding this comment.
Converting headers to a plain object using Object.fromEntries(request.headers.entries()) could potentially cause issues if there are duplicate header names (though unlikely with Accept-Language). Additionally, this creates a new object on every call. Consider passing the headers directly if the Negotiator API supports it, or at least document why this conversion is necessary.
| const negotiatorHeaders = Object.fromEntries(request.headers.entries()); | |
| const negotiator = new Negotiator({ headers: negotiatorHeaders }); | |
| const acceptLanguage = request.headers.get('accept-language'); | |
| if (!acceptLanguage) { | |
| return i18n.defaultLanguage; | |
| } | |
| const negotiator = new Negotiator({ | |
| headers: { 'accept-language': acceptLanguage }, | |
| }); |
| function setLocaleCookie(response: NextResponse, locale: string): void { | ||
| response.cookies.set(LOCALE_COOKIE, locale, { | ||
| sameSite: 'lax', | ||
| path: '/', |
There was a problem hiding this comment.
The cookie should also set the secure flag in production environments to ensure it's only transmitted over HTTPS. Consider adding secure: process.env.NODE_ENV === 'production' to the cookie options.
| path: '/', | |
| path: '/', | |
| secure: process.env.NODE_ENV === 'production', |
The homepage now redirects users to their language-preferred interface based on browser
Accept-Languageheaders. Chinese users are redirected to/cn, while English users remain on/(the default).Changes
Custom Middleware (
apps/docs/middleware.ts)zh,zh-CN,zh-TW,zh-HK) →cn/en, URL stays/sameSite: 'lax',path: '/'Dependencies (
apps/docs/package.json)negotiatorforAccept-Languageparsing@types/negotiatorImplementation
Behavior
en-US//(rewritten to/eninternally)zh-CN//cn/en/(strips default locale)Screenshots
English Homepage (

/)Chinese Homepage (

/cn)Original prompt
✨ Let Copilot coding agent set things up for you — coding agent works faster and does higher quality work when set up for your repo.