1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

このガイドでは、以下のことを学びます:

  • アプリケーション全体(サーバーサイドであれクライアントサイドであれ)で翻訳を実装する方法
  • 翻訳済み文字列にプロパティやHTMLの追加方法
  • 国ごとの日付と通貨のフォーマットに関するメモ

    …そしてさらに!

自分が読める言語で書かれた文章を見ると、ほっとしますよね。
Next.js 16 アプリで最適なユーザー体験を提供し、多言語対応を実装する準備はできましたか?
それでは、始めましょう!

まず、全体像を把握しましょう

どこへ行くかを知るには、まず自分がどこにいるかを知っておく必要があります。
本チュートリアルでは、このリポジトリを出発点とします。
すべてが日本語で、ハードコードされた文字列が含まれています。

もちろん、ゼロから独自のプロジェクトを始めたい場合や、すでに独自のアプリケーションをお持ちの場合でも、問題なく手順に沿って進めることができます。
型安全性を確保するため、TypeScriptを使用して進めていきます。

このアプリケーションに英語オプションを追加します。
アプリでさらに言語を追加したい場合や、他の言語を追加したい場合も、手順はほぼ同様ですので、その点についても併せて説明します。

リポジトリのブランチについて

P.S.: 推奨されているリポジトリを使用していない場合は、このセクションを読み飛ばしてください。

  • initial: これは翻訳前のプロジェクトが置かれているブランチです。チュートリアルをゼロから始めたい場合は、こちらから始めてください。
  • master: ここではリポジトリが100%翻訳されています。i18n化された完成品のプロジェクト例を見たい場合は、このブランチを使用してください。
  • test: このブランチは記事の内容を厳密に追従し、最後に至るまで、明示的に記載されている翻訳や変更以外のものは一切適用されません。

チュートリアルを始める際は、必ず initial ブランチから始めてください。記事の手順を一つずつ進め、test ブランチの状態を目指していきましょう。
このプロジェクトを完全に翻訳するのは結構大変かもしれません。もし途中でつまずいても、決して落ち込む必要はありません。解答となるブランチを参考にしながら進めても全く問題ありません。

基本設定

ご想像の通り、まず最初に行うべきことは、いくつかのパッケージをインストールすることです。

npm install i18next react-i18next

それでは、いくつかの基本的なファイルを作成しよう。まず、プロジェクトの基本設定が必要です。

このファイルを i18n-config.ts と名付け、srcフォルダに配置してください。

export const i18n = {
  defaultLocale: "ja",
  locales: ["en", "ja"],
} as const;

export type Locale = (typeof i18n)["locales"][number];

export const hasLocale = (locale: Locale) => {
  return i18n.locales.includes(locale);
}

このファイルでは、ウェブサイトのデフォルト言語を定義します。使用する言語の配列を記述し、アプリ内に存在する言語と照合して一致するかどうかを確認する簡単なチェックを実装しています。

次に、翻訳データを読み込みます。Node.jsのビルド時に import() がバンドリングの問題を引き起こす可能性があるため、翻訳データは別のファイルとして用意する必要があります。

srcフォルダに入れて、「locales.ts」という名前を付けましょう。

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

const locales = {
  en: () => import("../locales/en.json").then((module) => module.default),
  ja: () => import("../locales/ja.json").then((module) => module.default),
};

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

読み込まれたパスに対応するフォルダとファイルも必ず作成してください。他の言語を使用する場合は、それに応じて追加してください。コンパイルエラーを防ぐため、すべての翻訳ファイルに空のオブジェクト {} を追加しておきましょう。後でそこに翻訳された文字列をすべて追加しますが、まずは設定を続けましょう。

app フォルダの直下に [lang] という名前のフォルダを作成してください。角括弧は重要で、lang が動的パラメータであることを示します。[lang] の中にはすべてのページを配置します。

directory.png
現在のディレクトリ構造は以下のようになっています

現在、アプリは URL に手動でパラメータを追加しないとアクセスできません。これを修正するため、app フォルダと同じ階層に proxy.ts ファイルを作成します。ただし、Next.jsのv16以前を使用している場合は、代わりにミドルウェアファイルが必要になります。

import { NextRequest, NextResponse } from "next/server";
import { i18n, Locale } from "./i18n-config";

const { defaultLocale, locales } = i18n;

function getLocale(request: NextRequest) {
  const acceptLanguage = request.headers.get("accept-language");
  if (!acceptLanguage) return defaultLocale;

  const preferredLocales = acceptLanguage.split(",").map((lang) => {
    const code = lang.split(";")[0].replace(/\s+/g, "");
    return code.substring(0, 2);
  });

  const matched = preferredLocales.find((lang) =>
    locales.includes(lang as Locale),
  );
  return matched ?? defaultLocale;
}

export function proxy(request: NextRequest) {
  const { pathname } = request.nextUrl;
  const pathnameHasLocale = locales.some(
    (locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`,
  );

  if (pathnameHasLocale) return;

  const locale = getLocale(request);
  request.nextUrl.pathname = `/${locale}${pathname}`;

  const response = NextResponse.redirect(request.nextUrl);
  return response;
}

export const config = {  matcher: [
    "/((?!_next|public|favicon\\.ico|.*\\.(?:png|jpg|jpeg|gif|webp|svg|ico)$).*)",
  ],
};

URL に言語パラメータが含まれていなくても、自動的にリダイレクトが行われます。ただし、コンテンツ自体はまだ変更していないため、すべての言語で同じ文字列が表示されたままになります。

次に、それを処理していきましょう! アプリケーション全体を囲む layout.tsx ファイルを開きます。HTML に lang 属性がありますが、この値を静的にしたままにはしたくありません。

今後は URL パラメータからロケールを読み取るようにします。

export default async function RootLayout({ 
  children,
  params, 
}: {
  children: React.ReactNode;
  params: Promise<{ lang: Locale }>; // @/i18n-config から
}) {
  const { lang } = await params;
  return (
    <html lang={lang}

重要な点は、関数を非同期(async)にし、プロパティにパラメータを追加し、await してパラメータから lang を取得することです。

ブラウザで表示を確認し、URL の言語を変更すると <html lang=""> が切り替わることを確認してください。有効なのは設定ファイルに記載された言語のみです。

同じファイルにも以下を追加します。

export async function generateStaticParams() {
  return [{ lang: "en" }, { lang: "ja" }];
}

近づいてきました!
次に、ユーザーに言語変更の組み込み機能を提供したいので、ロケールスイッチャーを作成します。複数の言語を追加する場合でも役立つよう、オプション付きのドロップダウンは拡張性のある選択です。components フォルダに追加します。

"use client";

import { useRouter, usePathname } from "next/navigation";
import { useTransition } from "react";
import { Locale } from "@/i18n-config";
import { useTranslation, initReactI18next } from "react-i18next";

import i18next from "i18next";
import en from "../../locales/en.json";
import ja from "../../locales/ja.json";

if (!i18next.isInitialized) {
  i18next.use(initReactI18next).init({
    resources: {
      en: { locale: en },
      ja: { locale: ja },
    },
    fallbackLng: "ja",
    ns: ["locale"],
    defaultNS: "locale",
    interpolation: {
      escapeValue: false,
    },
  });
}

const locales: Record<Locale, { name: string; flag: string }> = {
  en: { name: "English", flag: "🇬🇧" },
  ja: { name: "日本語", flag: "🇯🇵" },
};

export default function LanguageSwitcher() {
  const router = useRouter();
  const pathname = usePathname();
  const { i18n } = useTranslation();
  const [isPending, startTransition] = useTransition();

  const currentLocale = i18n.language;

  const handleChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
    const newLocale = event.target.value as Locale;

    startTransition(async () => {
      await i18n.changeLanguage(newLocale);

      const newPath = pathname.replace(`/${currentLocale}`, `/${newLocale}`);
      router.push(newPath);
    });
  };

  return (
    <div
      style={{ display: "inline-flex", alignItems: "center", gap: "0.5rem" }}
    >
      <select
        id="language-select"
        value={currentLocale}
        onChange={handleChange}
        disabled={isPending}
      >
        {Object.entries(locales).map(([code, { name, flag }]) => (
          <option key={code} value={code}>
            {flag} {name}
          </option>
        ))}
      </select>
      <span
        style={{
          display: "inline-block",
          width: "16px",
          height: "16px",
          visibility: isPending ? "visible" : "hidden",
        }}
        aria-hidden={!isPending}
      >
        {isPending && <div className="spinner" />}
      </span>
    </div>
  );
}

参考リポジトリでは、このコンポーネントを SiteHeader.tsx の最後の <Link> のすぐ後にインポートしてください。

languageSwitcher.png
UIに実装された言語スイッチャー

新しい LanguageSwitcher を試し、URL が正しく切り替わることを確認してください。

ハードコードされた文字列から、翻訳ファイルから取得したテキストへ

Next.js で翻訳を使用する際、クライアント側またはサーバー側で読み込むことができます。一般的にはサーバー側の方が好まれます。それは処理が瞬時に完了するためです。まずはこの方法について説明しましょう。

SiteHeader.tsx を起点とするのは、ページ読み込み時にこのコンポーネントが明確に表示されるためです。

14 行目には、このハードコードされた文字列が表示されます。「日本各地の売買・賃貸をワンストップでサポートします」は、英語ではおおよそ「One-stop support for buying, selling, and renting throughout Japan」と訳されます。

プロジェクト内のすべての翻訳済み文字列は、JSON 値として表現されます。この例では slogan が適切なキー名となるでしょう。したがって、en.json には以下のように記述します。

{slogan: "One-stop support for buying, selling, and renting throughout Japan"}

ja.json には以下のように記述します。

{slogan: "日本各地の売買・賃貸をワンストップでサポートします"}

tsx ファイルでは、ハードコードされた文字列を {t["slogan"]} に置き換えます。

t という名前は任意で構いません。「translate」という完全な単語を使いたい場合は、そのように名付けても問題ありません。

さて、翻訳を追加するコンポーネントに変更を加えましょう。

まず、コンポーネントをサーバーコンポーネントにする必要があります。

ロケールをパラメータから抽出する必要があるため、まず親ページでそれを取得します。
[lang]/page.tsx:

export default async function Home({
  params,
}: {
  params: Promise<{ lang: Locale }>;
}) {
  const { lang } = await params;
  const t = await getTranslation(lang);

  return (
    <div className={styles.page}>
      <SiteHeader active="home" lang={lang} t={t}  />
      ...

SiteHeaderが存在する他のページにも、新しいコードを適用してください。
[propertyId]/page.tsx 難しいかもしれないから,問題が発生した場合は、testブランチを参照してください。
その後、翻訳が使用されるコンポーネントにそれを渡します。今回の場合、SiteHeader.tsx です。

export async function SiteHeader({
  active,
  lang,
  t,
}: {
  active: NavKey;
  lang: Locale;
  t: Awaited<ReturnType<typeof getTranslation>>;
}

大半のケースでは、ロケールと getTranslation の両方を渡す必要はなく、後者のみで十分です。しかし、SiteHeader.tsx にはナビゲーションリンクが含まれているため、ローカライズされた URL を構築するために lang を追加する必要があります。
現在のロケールを尊重する必要があるリンクを含むコンポーネントには、このパターンを忘れないでください。

  <Link href={`/${lang}/`} className={styles.brand}>
    ...
  </Link>
  <nav className={styles.nav} aria-label="主要ナビゲーション">
    <Link
      href={`/${lang}/`} >
      ...
    </Link>
    <Link
      href={`/${lang}/about`} 
      ...

これでローカライズされた文字列が表示され、移動中に正しいロケールが維持されるはずです。極端に単純化したバージョンはこうなります。すべてのサーバーコンポーネントでこれを行えば完了です。

クライアントサイド

対話型のクライアントコンポーネントや、サーバーレンダリングが不可能な場合は、異なるパターンを使用する必要があります。

React Context に既に慣れているなら、ツリー全体でプロパティを無限に渡さずにアプリ全体で情報を簡単に提供する方法だと知っているはずです。同様に、layout.tsx でプロバイダーを使用して、アプリ全体に言語を渡すことができます。
まず、プロバイダを専用のファイルに作成しましょう。

"use client";

import { ReactNode, useEffect } from "react";
import { I18nextProvider } from "react-i18next";
import { Locale } from "@/i18n-config";
import i18next from "i18next";

export function I18nProvider({
  children,
  lang,
}: {
  children: ReactNode;
  lang: Locale;
}) {
  useEffect(() => {
    if (!i18next.isInitialized || i18next.language !== lang) {
      i18next.changeLanguage(lang);
    }
  }, [lang]);

  return <I18nextProvider i18n={i18next}>{children}</I18nextProvider>;
}

次に、プロジェクトのメインの layout.tsx ファイルにプロバイダーを追加してください。

  const { lang } = await params;
  return (
    <html lang={lang} className={notoSansJp.variable}>
      <body> 
        <I18nProvider lang={lang}>{children}</I18nProvider>
      </body>
    </html>
  );

LastVisitNotice.tsx はクライアントコンポーネントであるため、このプロバイダーのテストに最適なケースです。
ハードコードされた文字列 ※表示される日付はいつも今日の日付です(デモ用のローカル表示){t("lastVisit.dateWarning")} に置き換え、その後 lastVisit.dateWarning を JSON ファイルに追加します。

en.json

{
  "slogan": "One-stop support for buying, selling, and renting throughout Japan",
  "lastVisit": {
    "dateWarning": "The displayed date is always today's date (local display for demo purposes)"
  }
}

ja.json

{
  "slogan": "日本各地の売買・賃貸をワンストップでサポートします",
  "lastVisit": {
    "dateWarning": "※日付はこのブラウザに保存した前回アクセス時刻です(デモ用のローカル表示)"
  }
}

LastVisitNotice.tsx においても、翻訳の読み込みを忘れないでください:

const { t } = useTranslation();

...そして、インポートを含めてください:

import { useTranslation } from "react-i18next";

クライアントサイドでの読み込みに関する考慮事項

クライアントサイドで翻訳を読み込む際、Flash of Untranslated Content (FOUC) が発生する可能性があります。これは UX 的に好ましくありません。

badUi.png
このページは英語で表示されるべきですが、読み込み時に一時的に日本語のテキストが表示される可能性があります。


これを防ぐには、ロケールが解決される間に読み込み状態 (loading state) を実装してください。LastVisitNotice.tsx でそのデモを確認できます。

loading.png
読み込み状態を実装すると、代わりにコンポーネントのスケルトンが表示され、その後、期待される文字列が表示されます。


___
これで、サーバーコンポーネントとクライアントコンポーネントの両方に対する基本チュートリアルは終了です。 要点を振り返りましょう:
  • サーバーコンポーネントでは、ページからパラメータを取得して正しい辞書を読み込むことができます
  • クライアントコンポーネントでは、プロバイダーを共有することができます
  • FOUC を防ぐため、クライアントコンポーネントには読み込み状態を実装してください
  • すべての内部リンクにロケールを含めるよう更新することを忘れないでください
  • 対応する JSON ファイルにすべての翻訳済み文字列を含め、tsx 内で適切に置き換えてください
    上記の手順を再現すれば、ゼロから多言語アプリケーションを提供するか、現在の Next.js 16 プロジェクトに変更を加えることができるはずです。

しかし、言語の変更が単語だけにとどまるわけではないため、さらに深く掘り下げることができます。

プロパティと HTML の追加

次の例では、引き続き LastVisitNotice.tsx で作業し、まだ翻訳されていない文字列を見てみましょう。これまで翻訳してきた文字列よりもはるかに複雑です。プロパティが 2 つ、<strong> タグが 3 つ含まれています。

これを処理する方法はいくつかあります。一つの方法として、テンプレート文字列を使用することが考えられます。

{t("lastVisit.mainText", {
  date: formatVisitDate(),
  count: NEW_LISTING_COUNT,
})}
"lastVisit.mainText": "前回のご訪問は {{date}} でした。その後、新着物件が {{count}} 件 加わっています。一覧では NEW の付いた物件を先頭に並べています。"

ローカル形式での日付表示

formatVisitDate() 関数にロケールパラメータを受け取るように変更した点にもお気づきかと思いますが、この関数をコンポーネント内部に移動させました。こうすることで t() にアクセスできるようになり、JSON ファイルには「不明な日時」に対応する文字列も追加しています。

function formatVisitDate() {
  const d = new Date();
  if (Number.isNaN(d.getTime())) return t("unknownDate");
  return new Intl.DateTimeFormat(locale, {
    year: "numeric",
    month: "long",
    day: "numeric",
    weekday: "short",
  }).format(d);
}

コンテキストを通じてロケールを取得できます:

const { t, i18n } = useTranslation();
const locale = i18n.language;

インポートも以下のように更新されます:

import { Trans, useTranslation } from "react-i18next";

テンプレート文字列は、翻訳にプロパティを追加するだけの場合には非常に効果的です。しかし、このソリューションから <strong> タグを削除した点にお気づきかもしれません。それは通常、React はテキスト内のタグを読み取ることができず、dangerouslySetInnerHTML のようなものを追加する必要が出てくるためで、私はこれを避けることを好みます。

文字列内で HTML が必要な場合は、react-i18next ライブラリにある Trans という便利なコンポーネントを使用できます。Trans はクライアントコンポーネントでのみ使用可能です。 以下のコードに段落を置き換えてみてください:

<Trans
  i18nKey="lastVisit.mainText"
  values={{
    date: formatVisitDate(locale),
    count: NEW_LISTING_COUNT,
  }}
/>

これで、翻訳ファイル内で HTML タグを直接使用できるようになりました!

ja.json

 "mainText": "前回のご訪問は <strong>{{date}}</strong> でした。その後、新着物件が <strong>{{count}} 件</strong> 加わっています。一覧では <strong>NEW</strong> の付いた物件を先頭に並べていま。"

en.json

 "mainText": "Your last visit was <strong>{{date}}</strong>. Since then, <strong>{{count}} new listings</strong> have been added. In the list, listings with <strong>NEW</strong> are displayed at the top."

翻訳文字列の分割

<Trans> やテンプレート文字列を使う代わりに、複数の文字列に分けることはできないか?」とお考えかもしれません。 例えば、"Your last visit was" という文字列を表示し、tsx でプロパティを通常通り表示し、続けて . Since then, " と続き、その後に次のプロパティを表示するという具合です。 そうすれば、テンプレート文字列や外部コンポーネントの必要はなくなります。

dangerouslySetInnerHTML を避けたい場合、これはサーバーサイドの翻訳におけるまさにそのパターンです(<Trans> は利用できないため)。 しかし、クライアントサイドではそのような分割をする必要はありません。それどころか、読み書きが難しくなるだけでなく、言語によって語順や構文が異なるため、誤りが発生しやすくなります。 上記の例でも、英語では値の直後に句点が来るのに対し、日本語ではさらに単語が入ります。他の言語になれば構文はさらに多様になり、値が文の先頭に来ることも考えられます。 これらの違いをすべて考慮し、一目で理解できるようにするため、クライアントコンポーネントを扱う際は <Trans> またはテンプレート文字列を使用してください。

命名について

これまでチュートリアルに厳密に従い、追加の翻訳を行っていない場合、現在のロケールファイルには 4 つのキーしか含まれていません。

{
  "lastVisit": {
    "dateWarning": "※表示される日付はいつも今日の日付です(デモ用のローカル表示)",
    "mainText": "前回のご訪問は <1>{{date}}</1> でした。その後、新着物件が <3>{{count}} 件</3> 加わっています。一覧では <5>NEW</5> の付いた物件を先頭に並べていま。"
  },
  "slogan": "日本各地の売買・賃貸をワンストップでサポートします",
  "unknownDate": "不明な日時"
}

今は簡単ですが、文字列が増えれば増えるほど、かなり混乱を招くことになります。

ロケールファイルの構成に唯一正しい方法はありません。アプリケーションの要件によって異なります。 最も重要なルールは「一意性」と「文脈」です。例えば、「mainText」のような一般的なキーを単独で使うのは避けてください。「どの mainText なの?」と疑問に思われてしまいます。 これを lastVisit にネストすることで、明確になり、自己説明的になります。

また、文字列をアルファベット順に保つことをお勧めします。ファイルを英語の文字順(A-Z)で容易に再排序するために、https://novicelab.org/jsonabc/ というウェブサイトを使用しました。

このより拡張された例は、すべての文字列が翻訳されたプロジェクトの最終バージョン(master ブランチ)で確認できます。

これまでのチュートリアルでは、en.json または ja.json のどちらかからすべてのキーを読み込みましたが、大規模なアプリケーションでは、ロケールごとに複数のファイルに分けることで利点が得られることが多いです。 各言語に対応するフォルダを持ち、各ロケールファイルがアプリケーション内のページやコンポーネントに対応させることも可能です。 例:

  • locales
    • en/
      • aboutPage.json
      • lastVisitNotice.json
      • propertyDetail.json
      • ...
    • jp/
      • aboutPage.json
      • lastVisitNotice.json
      • propertyDetail.json
      • ...

それを実装するには、I18nProvider 内で実行している i18n の初期化と locales.ts 内の設定に、新しいファイルを含めるようにしてください。 また、getTranslation を変更して、フォルダ内のロケールを検索し、意図するファイルを読み込むための 2 番目のパラメータを受け取るようにする必要があります。

ボーナス:通貨

通貨は複雑なトピックであり、それ自体の記事に値します。

現実のシナリオでは、通貨と言語を別々に追跡する必要があります。 例えば、プロジェクトで海外に住む日本人があなたのウェブサイトから何かを購入しようとしている場合を考えてみましょう。 支払いはユーロで行うかもしれませんが、UI は母国語である日本語の方が好まれる可能性が高いです。 同じ言語でも異なる通貨を表す場合があります。米国と英国の両方で英語が話されますが、一方はドル、もう一方はポンドを使用します。

最もデリケートな問題は為替レートの変換です。レートは常に変動しており、フロントエンド側でそのような計算を行うのは一般的に賢明ではありません。 多くの場合、バックエンドから正しい生数値を送ってもらい、tsx では変換処理のみを担当するようにしたいはずです。

その際、Intl.NumberFormat や currency.js、money.js などのライブラリを使用できます。
普段、通貨と翻訳を扱う際、どのように対応していますか?
次に何を話しましょうか?

コメント欄であなたのアプローチやご意見をお聞かせください。またすぐにお話しできるのを楽しみにしています!

ご活用いただけた場合は、プロフィールに記載のリンクよりフォロー・追加をお願いできますと幸いです。


※本記事は私が初めて執筆した日本語の技術記事です。正確性を心がけておりますが、不自然な表現や誤りがあるかもしれません。日本語の表現や内容についてご指摘・ご提案をいただけると大変嬉しく思います。

1
0
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?