🗣️

Next.js(App Router) 多言語対応の手順

2024/07/31に公開

Next.js App Router のInternationalizationを使ってNext.jsの多言語化対応の手順についての備忘録です。


参考一覧

バージョン情報

Next.js 14.2.4

DEMO

DEMO GIF

Github / デモサイト
https://github.com/masterak-902/hsweb-portfolio-1

ソースコード

ミドルウェア

ミドルウェアの役割について

ミドルウェアを使えば、リクエストが完了する前にコードを実行することができる。そして、送られてきたリクエストに基づいて、レスポンスを書き換えたり、リダイレクトしたり、リクエストやレスポンスのヘッダーを変更したり、直接レスポンスしたりすることで、レスポンスを変更することができる。
ミドルウェアはキャッシュされたコンテンツとルートがマッチする前に実行されます。詳細はパスのマッチングを参照してください。
https://nextjs.org/docs/app/building-your-application/routing/middleware

多言語設定を行うミドルウェアでは、ページのGETリクエストに言語指定が含まれているかを確認して、最適な言語へリダイレクトする機能をページレンダリングを実行する前に行います。

middleware関数

middleware.ts
export function middleware(request: NextRequest) {
  const pathname = request.nextUrl.pathname;

  const pathnameIsMissingLocale = i18n.locales.every(
    (locale) =>
      !pathname.startsWith(`/${locale}/`) && pathname !== `/${locale}`,
  );

  if (pathnameIsMissingLocale) {
    const locale = getLocale(request);

    return NextResponse.redirect(
      new URL(
        `/${locale}${pathname.startsWith("/") ? "" : "/"}${pathname}`,
        request.url,
      ),
    );
  }
}
  • リクエストのパスにロケールが含まれていないかをチェックします。
  • ロケールが含まれていない場合、getLocale 関数を使用して最適なロケールを取得し、そのロケールを含む新しいURLにリダイレクトします。

getLacale関数

middleware.ts
function getLocale(request: NextRequest): string | undefined {
  const negotiatorHeaders: Record<string, string> = {};
  request.headers.forEach((value, key) => (negotiatorHeaders[key] = value));

  const locales: string[] = i18n.locales;

  let languages = new Negotiator({ headers: negotiatorHeaders }).languages(locales);

  const locale = matchLocale(languages, locales, i18n.defaultLocale);

  return locale;
}
  • Negotiator を使用してリクエストヘッダーから言語情報を取得し、matchLocale を使用して最適なロケールを選択します。

configオブジェクト

middleware.ts
export const config = {
  matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
};
  • ミドルウェアから除外するパスを指定
middleware.ts 全文
middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

import { i18n } from "./i18n-config";

import { match as matchLocale } from "@formatjs/intl-localematcher";
import Negotiator from "negotiator";

function getLocale(request: NextRequest): string | undefined {
  // Negotiator expects plain object so we need to transform headers
  const negotiatorHeaders: Record<string, string> = {};
  request.headers.forEach((value, key) => (negotiatorHeaders[key] = value));

  // @ts-ignore locales are readonly
  const locales: string[] = i18n.locales;

  // Use negotiator and intl-localematcher to get best locale
  let languages = new Negotiator({ headers: negotiatorHeaders }).languages(
    locales,
  );

  const locale = matchLocale(languages, locales, i18n.defaultLocale);

  return locale;
}

export function middleware(request: NextRequest) {
  const pathname = request.nextUrl.pathname;

  // // `/_next/` and `/api/` are ignored by the watcher, but we need to ignore files in `public` manually.
  // // If you have one
  // if (
  //   [
  //     '/manifest.json',
  //     '/favicon.ico',
  //     // Your other files in `public`
  //   ].includes(pathname)
  // )
  //   return

  // Check if there is any supported locale in the pathname
  const pathnameIsMissingLocale = i18n.locales.every(
    (locale) =>
      !pathname.startsWith(`/${locale}/`) && pathname !== `/${locale}`,
  );

  // Redirect if there is no locale
  if (pathnameIsMissingLocale) {
    const locale = getLocale(request);

    // e.g. incoming request is /products
    // The new URL is now /en-US/products
    return NextResponse.redirect(
      new URL(
        `/${locale}${pathname.startsWith("/") ? "" : "/"}${pathname}`,
        request.url,
      ),
    );
  }
}

export const config = {
  // Matcher ignoring `/_next/` and `/api/`
  matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
};

ロケールスイッチ(言語切替スイッチ)

このコンポーネントは、ユーザーがボタンをクリックすることで、ページのロケールを "ja"(日本語)と "en"(英語)の間で切り替えることができる機能を提供します。ロケールが切り替わると、URL が更新され、対応する言語のページにリダイレクトされます。

LocaleSwitcher コンポーネント

export default function LocaleSwitcher() {
  const pathName = usePathname();
  const router = useRouter();
  const [currentLocale, setCurrentLocale] = useState<Locale>("ja");

  useEffect(() => {
    const segments = pathName?.split("/");
    if (segments && i18n.locales.includes(segments[1] as Locale)) {
      setCurrentLocale(segments[1] as Locale);
    }
  }, [pathName]);
  • pathName には現在のパスが格納されます。
  • router はルーターオブジェクトで、ページ遷移に使用します。
  • currentLocale は現在のロケールを管理するための状態です。
  • useEffect フックは、パスが変更されたときに実行され、パスのセグメントからロケールを取得して currentLocale を更新します。

toggleLocale関数

  const toggleLocale = () => {
    const newLocale: Locale = currentLocale === "en" ? "ja" : "en";
    const redirectedPathName = (locale: Locale) => {
      if (!pathName) return "/";
      const segments = pathName.split("/");
      segments[1] = locale;
      return segments.join("/");
    };
    router.push(redirectedPathName(newLocale));
    setCurrentLocale(newLocale);
  };
  • toggleLocale 関数は、現在のロケールを切り替え、新しいロケールに基づいてパスを再構築し、ページをリダイレクトします。
  • newLocale は現在のロケールが "en" なら "ja" に、"ja" なら "en" に切り替えます。
  • redirectedPathName 関数は、新しいロケールを含むパスを生成します。
  • router.push を使用して新しいパスにリダイレクトし、currentLocale を更新します。
Locale-Switcher 全文
/components/Locale-Switcher/index.tsx
"use client";

import { usePathname, useRouter } from "next/navigation";
import { i18n, type Locale } from "@/i18n-config";
import { useState, useEffect } from "react";
import styles from "./index.module.css";

export default function LocaleSwitcher() {
  const pathName = usePathname();
  const router = useRouter();
  const [currentLocale, setCurrentLocale] = useState<Locale>("ja");

  useEffect(() => {
    const segments = pathName?.split("/");
    if (segments && i18n.locales.includes(segments[1] as Locale)) {
      setCurrentLocale(segments[1] as Locale);
    }
  }, [pathName]);

  const toggleLocale = () => {
    const newLocale: Locale = currentLocale === "en" ? "ja" : "en";
    const redirectedPathName = (locale: Locale) => {
      if (!pathName) return "/";
      const segments = pathName.split("/");
      segments[1] = locale;
      return segments.join("/");
    };
    router.push(redirectedPathName(newLocale));
    setCurrentLocale(newLocale);
  };

  return (
    <div>
      <button className={`${styles.switcher} ${styles.active}`} onClick={toggleLocale}>
        {currentLocale === "ja" ? "English" : "日本語"}
      </button>
    </div>
  );
}
index.module.css
.switcher {
    display: inline-block;
    padding: 10px 20px;
    border: 1px solid #ccc;
    border-radius: 5px;
    cursor: pointer;
    text-decoration: none;
    color: #333;
    transition: background-color 0.3s, color 0.3s;
  }
  
  .switcher:hover {
    background-color: #333;
    color: #fff;
  }
  
  .active {
    background-color: #fff0;
    color: #333;
    border-color: #333;
  }

コンフィグファイルについて

コンフィグファイルは、ルートディレクトリに配置します。

i18n-config.ts
export const i18n = {
    defaultLocale: "ja",
    locales: ["en", "ja"],
  } as const;
  
  export type Locale = (typeof i18n)["locales"][number];

ロケールの型情報と、デフォルトのロケールを設定します。

get-dictionary.ts
import "server-only";
import type { Locale } from "./i18n-config";

// We enumerate all dictionaries here for better linting and typescript support
// We also get the default import for cleaner types
const dictionaries = {
  en: () => import("./dictionaries/en.json").then((module) => module.default),
  ja: () => import("./dictionaries/ja.json").then((module) => module.default)
};

export const getDictionary = async (locale: Locale) => dictionaries[locale]?.() ?? dictionaries.ja();

このコードは、サーバーサイドで使用される辞書データを動的にインポートし、指定されたロケールに対応する辞書データを取得するための関数を提供します。
これにより、異なる言語の辞書データを効率的に管理し、必要に応じて動的にロードすることができます。

// /dictionaries/en.json
{
  "server-component": {
    "a": "Welcome to the new server component interface. This area allows you to manage and monitor all your server activities.",
    "b": "The quick brown fox jumps over the lazy dog. This sentence contains every letter in the English alphabet.",
    "c": "Ensuring that every user has a seamless and efficient experience is our top priority. We continuously strive for excellence.",
    "d": "This is a test sentence designed to verify language display. It is important for multilingual applications to be accurate.",
    "e": "The advancement of technology has significantly impacted our daily lives, providing convenience and efficiency in various tasks.",
    "f": "Learning a new language can be challenging but also incredibly rewarding. It opens up new opportunities and perspectives.",
    "g": "Effective communication is key to success in any field. Clear and concise messaging ensures that your audience understands your point.",
    "h": "In today's globalized world, being multilingual is an invaluable skill. It enhances personal and professional growth.",
    "i": "Reading books is a great way to gain knowledge and relax. It stimulates the mind and enhances creativity.",
    "j": "Staying updated with current events helps us make informed decisions. It is essential to be aware of what is happening around us."
  }
}

// /dictionaries/ja.json
{
  "server-component": {
    "a": "新しいサーバーコンポーネントインターフェースへようこそ。このエリアでは、すべてのサーバーアクティビティを管理および監視できます。",
    "b": "素早い茶色の狐が怠けた犬の上を飛び越える。この文章は英語アルファベットのすべての文字を含んでいます。",
    "c": "すべてのユーザーにシームレスで効率的な体験を提供することが私たちの最優先事項です。私たちは常に卓越性を追求しています。",
    "d": "これは言語表示を検証するためのテスト文です。多言語アプリケーションが正確であることが重要です。",
    "e": "技術の進歩は私たちの日常生活に大きな影響を与え、さまざまなタスクにおいて利便性と効率を提供しています。",
    "f": "新しい言語を学ぶことは挑戦的ですが、非常にやりがいがあります。それは新しい機会と視点を開きます。",
    "g": "効果的なコミュニケーションはどの分野においても成功の鍵です。明確で簡潔なメッセージングは、聴衆があなたのポイントを理解するのに役立ちます。",
    "h": "今日のグローバル化された世界では、多言語が非常に価値のあるスキルです。それは個人的および職業的な成長を促進します。",
    "i": "読書は知識を得てリラックスするのに最適な方法です。それは心を刺激し、創造性を高めます。",
    "j": "現在の出来事を把握することは、私たちが情報に基づいた意思決定を行うのに役立ちます。周囲で何が起こっているのかを知ることが重要です。"
  }
}
GitHubで編集を提案

Discussion