Next.js Page Routerで英語圏のユーザーを/enにアクセスさせる

実現したい仕様
- localeをもとに、アクセスするページのパスを分岐させたい
- デフォルトは日本語
- en-USなど英語圏のユーザーのアクセス先はまとめて/enにしたい
- /enページにアクセスしたときも、日本語ページなど他言語のページに非同期遷移させるリンクを設定したい

Next.js Page Routerに標準搭載されているi18n Routingを使う場合、以下のようにsub-path-routingを設定しても、en
というlocaleは存在しないため、上記仕様を実現できない。en-US
を直接指定すればリダイレクトさせられるが、パスも/en-US
になってしまう。
i18n: {
locales: ['en', 'ja'], // これではアカン
defaultLocale: 'ja',
}
そのため、以下公式を参考にカスタムルーティングを実現する必要がありそう。
ただ、middleware側で制御する場合、next/linkやrouter.pushを使って別の言語ページに遷移させるのが難しくなった。Cookieを利用することで回避できそうだが、数分後に再訪した時に制御が難しくなりそう。History: pushState()とstateを組み合わせて行う方法もありそうだが、実装が複雑になりそう。

以下のように、domain-routingを指定する形でRouting自体は実現可能。ただこの方法だと(英語圏はen-USだけでないため)対応したい英語圏のlocale分記述を追加する必要がある。
i18n: {
locales: ['en', 'en-US', 'ja'],
defaultLocale: 'ja',
domains: [
{
domain: process.env.NEXT_PUBLIC_ORIGIN,
defaultLocale: 'ja',
http: process.env.NODE_ENV === 'development'
},
{
domain: `${process.env.NEXT_PUBLIC_ORIGIN}/en`,
defaultLocale: 'en-US',
http: process.env.NODE_ENV === 'development'
}
]
}

上記を採用した上で、他のlocaleのページにアクセスするリンクを設定する時は下記のように記述する必要がある。ただ、domain-routingを使用した場合、下記だと、日本語ページに遷移しない問題が発生する。localeをfalse
にしても結果は同じ。
ちなみにlocaleを空文字にすると、URL自体は遷移後のパス(言語ごとのページのパス)になるものの、locale自体は変化しない。
import Link from 'next/link'
const Page = () => {
return (
<>
<Link href='/' locale='ja'>
日本語ページへ
</Link>
<br />
<Link href='/' locale='en'>
英語ページへ
</Link>
</>
)
}
export default Page

localeを空文字に設定した場合、useStateやグローバルステートを使って言語設定を管理する方法が考えられる。
middlewareで/ja
が付与されているURLに対して/ja
を削除してリダイレクトさせることもトライしてみたが結局叶わず。Next.jsのi18n機能でlocaleDetection
がtrueになっている場合は、middlewareで後から操作できないことが分かった。

リダイレクトを日本語ページのTOPに訪れた場合に限定する場合は、Next.jsのi18n機能を使わずに、middlewareだけで制御可能。その場合は言語ごとにディレクトリを切るイメージ。
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
const PUBLIC_FILE = /\.(.*)$/
export function middleware(request: NextRequest) {
// 静的ファイルやAPI関連のリクエストはスキップ
if (
request.nextUrl.pathname.startsWith('/_next') ||
request.nextUrl.pathname.includes('/api/') ||
PUBLIC_FILE.test(request.nextUrl.pathname)
) {
return
}
const referer = request.headers.get('referer')
const acceptLanguage = request.headers.get('accept-language')
// TOPページアクセス時の言語リダイレクト処理
if (request.nextUrl.pathname === '/') {
// サイト内遷移の場合はリダイレクトしない
if (referer?.includes(request.nextUrl.origin)) {
return NextResponse.next()
}
// 英語ブラウザの場合、英語版にリダイレクト
if (acceptLanguage?.includes('en')) {
return NextResponse.redirect(new URL('/en', request.url))
}
}
return NextResponse.next()
}
// マッチするパス: トップページと英語以外のパス
export const config = {
matcher: ['/', '/((?!en).*)']
}
next/linkやrouter.pushでの遷移する場合、上記のようにmiddleware側でreferer
を取得すれば、リダイレクトさせないようにできる。
上記コードを少し修正すれば、TOPページに限定せずともリダイレクトさせられる。

上記コードを少し修正すれば、TOPページに限定せずともリダイレクトさせられる。
上記仕様+英語言語に限らないリダイレクト設定を実施
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
// 対応言語の定義
const LANGUAGES = [
'en', // English
'cn', // Chinese (Simplified)
'tw', // Chinese (Traditional)
'ko', // Korean
'th', // Thai
'fr' // French
] as const
type Language = (typeof LANGUAGES)[number]
// 静的アセット(画像、CSS、JSなど)を判別する正規表現
const PUBLIC_FILE = /\.(.*)$/
export function middleware(request: NextRequest) {
// 以下のリクエストは言語処理をスキップ:
// - Next.jsの内部リクエスト(/_next/)
// - APIリクエスト(/api/)
// - 静的ファイル(.jpg、.cssなど)
if (
request.nextUrl.pathname.startsWith('/_next') ||
request.nextUrl.pathname.includes('/api/') ||
PUBLIC_FILE.test(request.nextUrl.pathname)
) {
return NextResponse.next()
}
const { pathname, origin } = request.nextUrl
const referer = request.headers.get('referer')
// URLが既に言語プレフィックス(/en/、/cn/など)を含む場合はスキップ
if (LANGUAGES.some((language) => pathname.startsWith(`/${language}`))) {
return NextResponse.next()
}
// サイト内での画面遷移の場合は言語リダイレクトをスキップ
if (referer?.includes(origin)) {
return NextResponse.next()
}
// ブラウザの言語設定を取得してリダイレクト先を決定
const acceptLanguage = request.headers.get('accept-language')?.toLowerCase()
const preferredLanguage = getPreferredLanguage(acceptLanguage)
// 対応言語が見つかった場合、その言語のパスにリダイレクト
if (preferredLanguage) {
return NextResponse.redirect(new URL(`/${preferredLanguage}${pathname}`, request.url), 302)
}
return NextResponse.next()
}
const getPreferredLanguage = (acceptLanguage?: string): Language | null => {
if (!acceptLanguage) return null
// 中国語のバリエーション(zh-HK等)を簡体字中国語として扱う
const normalizedLanguage = /^zh(-hk)?$/.test(acceptLanguage) ? 'zh-cn' : acceptLanguage
// ブラウザの言語設定と対応言語を照合
return LANGUAGES.find((language) => normalizedLanguage.startsWith(language)) || null
}