🦜

Next.js + App Router + TypeScript での多言語対応

2024/09/19に公開

環境 | 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 に対応しています。
https://nextjs.org/docs/app/building-your-application/routing/internationalization

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 is en, 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

https://nextjs.org/docs/app/building-your-application/routing/internationalization

https://zenn.dev/masterak/articles/zenn_article_07

https://qiita.com/masakinihirota/items/873c2558e6d8864d8148

https://zenn.dev/mybest_dev/articles/324aed92f8086f

https://qiita.com/pepo/items/81e2b71b624633ba272e

Discussion