🦜
Next.js + App Router + TypeScript での多言語対応
環境 | Environment
- Next.js
- TypeScript
- App Router
- i18n
- 日・英の言語対応(デフォルトは日)
準備
1. negotiator と @formatjs/intl-localematcher をインストールする | Install negotiator and @formatjs/intl-localematcher
npm install negotiator @formatjs/intl-localematcher
- 💡negotiator の型定義ファイル
@types/negotiator
も入れておく。- 型定義は開発中のみ使う情報のため、devDependencies に入れます
npm install --save-dev @types/negotiator
多言語ページの作成
Next.js は built-in で i18n に対応しています。
1. ディレクトリの準備
今回は、app ディレクトリを以下のように準備。
src
├── app
│ ├── [lang]
│ │ ├── about
│ │ │ ├── page.module.scss
│ │ │ └── page.tsx
│ │ ├── layout.tsx
│ │ ├── page.module.scss
│ │ └── page.tsx
│ └── favicon.ico
2. i18n の設定ファイルを作成
src/i18n/i18n-config.ts
export const i18n = {
defaultLocale: "ja",
locales: ["en", "ja"],
} as const;
export type Locale = (typeof i18n)["locales"][number];
3. ローカリゼーション
3-1. 各言語の json ファイルの作成
src/i18n/dictionaries/en/common.json
{
"welcome": "Welcome"
}
src/i18n/dictionaries/ja/common.json
{
"welcome": "ようこそ"
}
3-2. 翻訳を返す関数の作成
指定された言語(ロケール)に応じて、適切な翻訳を非同期に読み込みます。
src/i18n/dictionaries.ts
import { type Locale } from "@/i18n/i18n-config";
const dictionaries = {
ja: () =>
import("./dictionaries/ja/common.json").then((module) => module.default),
en: () =>
import("./dictionaries/en/common.json").then((module) => module.default),
};
export const getDictionary = async (locale: Locale) => dictionaries[locale]();
3-3. 翻訳の表示
page コンポーネントで、getDictionary() をインポートし読み込みます。
src/app/[lang]/about/page.tsx
import { getDictionary } from "@/i18n/dictionaries"; //⭐️
// components...
// styles...
export default async function About({ params: { lang } }) {//⭐️
const dictData = await getDictionary(lang);//⭐️
const pageData = dictData.about;//⭐️
return (
<div className={styles.about}>
...
<div className={styles.container}>
{pageData.body.map((item, index) => {//⭐️
return (
<div key={index} className={commonStyles.textBlock}>
<h2>{item.heading}</h2>
<div className={commonStyles.textWrap}>
{item.paragraph.map((para, index) => {
return <p key={index}>{para}</p>;
})}
</div>
</div>
);
})}
...
</div>
</div>
);
}
ロケールに合わせたルーティングの設定
1. middleware の設定
リクエストされたパスに、ロケールが設定されてない場合のロケールの割り振りをいます。
まず、最終的なコードは以下。
src/middleware.ts
src/middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
// i18n
import { i18n } from "@/i18n/i18n-config";
// plugin
import Negotiator from "negotiator";
import { match as matchLocale } from "@formatjs/intl-localematcher";
// Get best locale by using negotiator and intl-localematcher
function getLocale(request: NextRequest): string | undefined {
const negotiatorHeaders: Record<string, string> = {};
request.headers.forEach((value, key) => (negotiatorHeaders[key] = value));
const locales = i18n.locales;
let languages = new Negotiator({ headers: negotiatorHeaders }).languages();
const locale = matchLocale(languages, locales, i18n.defaultLocale);
return locale;
}
export function middleware(request: NextRequest) {
const pathname = request.nextUrl.pathname;
// 1. リクエストのパスにロケールが含まれていないかをチェック
// Check if there is any supported locale in the pathname
const pathnameIsMissingLocale = i18n.locales.every(
(locale) => !pathname.startsWith(`/${locale}/`) && pathname !== `/${locale}`
);
// 2. ロケールが含まれていない場合、最適なロケールにリダイレクト
// Redirect to best locale when locale is missing
if (pathnameIsMissingLocale) {
const locale = getLocale(request);
return NextResponse.redirect(
new URL(
`/${locale}${pathname.startsWith("/") ? "" : "/"}${pathname}`,
request.url
)
);
}
}
export const config = {
matcher: ["/((?!api|_next/static|_next/image|images|favicon.ico).*)"],
};
middleware の解説
export function middleware(request: NextRequest) {
const pathname = request.nextUrl.pathname;
// 1. リクエストのパスにロケールが含まれていないかをチェック
// Check if there is any supported locale in the pathname
const pathnameIsMissingLocale = i18n.locales.every(
(locale) => !pathname.startsWith(`/${locale}/`) && pathname !== `/${locale}`
);
// 2. ロケールが含まれていない場合、最適なロケールにリダイレクト
// Redirect to best locale when locale is missing
if (pathnameIsMissingLocale) {
// 2-1. getLocale 関数(詳細は後述)を使用して最適なロケールを取得
const locale = getLocale(request);
// ⭐️2-2. そのロケールを含む新しいURLにリダイレクト
return NextResponse.redirect(
new URL(
`/${locale}${pathname.startsWith("/") ? "" : "/"}${pathname}`,
request.url
)
);
}
}
- ⭐️2-2. If incoming request is
/products
and current local isen
, the new URL will bacame/en/products
.
getLocale() の解説
// Get best locale by using negotiator and intl-localematcher
function getLocale(request: NextRequest): string | undefined {
// 1. Next.jsのリクエストヘッダーを、Negotiatorライブラリが期待する形式のオブジェクトに変換する
// Convert Next.js request headers into an object format expected by the Negotiator library.
const negotiatorHeaders: Record<string, string> = {}; //⭐️1-1.
request.headers.forEach((value, key) => (negotiatorHeaders[key] = value)); //⭐️1-2.
// 2. 最適なロケールを決定する Get best locale
const locales = i18n.locales;
let languages = new Negotiator({ headers: negotiatorHeaders }).languages(); //⭐️2-1.
const locale = matchLocale(languages, locales, i18n.defaultLocale); //⭐️2-2.
return locale;
}
-
- ⭐️1-1.
Record<string, string>
型の空のオブジェクトnegotiatorHeaders
を作成する。これは、すべてのキーと値が 文字列型 であるオブジェクトを表す - ⭐️1-2. request.headers (Next.jsのリクエストヘッダー) に対して forEach メソッドを使用して、各ヘッダーをループ処理する。
- 各ヘッダーについて、key (ヘッダー名) を新しいオブジェクトのキーとして、value (ヘッダーの値) をその値として設定する。
-
- ⭐️2-1.
languages()
メソッドは、リクエストの Accept-Language ヘッダーを解析し、優先順位順に言語タグの配列を返す - ⭐️2-2.
matchLocale
関数は、ユーザーの優先言語とアプリケーションがサポートするロケールを比較し、最適なマッチを返す。(マッチが見つからない場合は、デフォルトのロケールを返す。)- 各引数
-
languages
: ユーザーのリクエストから得られた優先言語の配列 -
locales
: アプリケーションでサポートされているロケールのリスト -
i18n.defaultLocale
: マッチするものが見つからない場合に使用するデフォルトのロケール
-
- 各引数
上記処理をする理由
perplexity より
- Next.jsの request.headers は Headers オブジェクトで、直接オブジェクトとして扱うことができません。
- Negotiatorライブラリは、プレーンなJavaScriptオブジェクトを期待します。
- この変換により、Negotiatorが理解できる形式でヘッダー情報を提供することができます。
結果として、negotiatorHeaders は元のリクエストヘッダーの内容を持つ通常のJavaScriptオブジェクトになります。これにより、Negotiatorライブラリを使用して言語やロケールのネゴシエーションを行うことができます。
言語切り替え機能の作成
TBD
参考 | Reference
Discussion