🌎

Next.js で i18n 対応するまで

2024/11/15に公開

はじめに

数年前から独学でプログラミングを学び、最近は個人開発をしている鈴木です。

今回は今開発している PlaylistManager というプロジェクトで、i18n対応するために必要だったことなどをまとめます。
実際の実装に関するコミットは以下の二つです:

前提

  1. すでにあるプロジェクトの i18n 対応なので、破壊的で大きな変更はしたくなかった。
  2. 言語を変更したときに、ほかのコンポーネントの状態は維持したいので、ページ全体のリロードを必要としない。

実装方針

  • 言語は URLに含まれているクエリ(?lang=ja)で決定する
    • パスに言語を含める方法(/[lang]/path/to/resource)もあるようだが、前提1を考慮してクエリに決定。
  • 言語変更は SelectMenu で実装

実装

必要なライブラリのインストール

今回のプロジェクトでは pnpm を使用しているので pnpm でインストールします。

pnpm add react-i18next i18next i18next-resources-to-backend

next-i18next の設定

今回は src/locales/[lang]/ 配下にそれぞれの言語のJSONファイルを配置することにします。

src/locales/en/common.json
{
    "title": "Sample title"
}
src/locales/ja/common.json
{
    "title": "サンプルタイトル"
}
src/locales/settings.ts
// これは URL に含まれるどのクエリを言語設定用にするかの項目です(今回は `?lang=en`)
export const QUERY_NAME = "lang";

// ここで指定する文字列は `src/locales/[lang]/` の lang に一致している必要があります。
export const DEFAULT_LANGUAGE = "ja";
export const AVAILABLE_LANGUAGES = [DEFAULT_LANGUAGE, "en"];

export const getOptions = (lang: string = DEFAULT_LANGUAGE) => {
	return {
		lng: lang,
		fallbackLng: DEFAULT_LANGUAGE,
		supportedLngs: AVAILABLE_LANGUAGES,
	};
};

URLに含まれるクエリから言語を決定するフックを作成する

実際の実装はここにあります

入力されたクエリから安全に言語を決定する関数

src/locales/settings.ts
// ...

export const getSafeLang = (lang: any) => {
	return typeof lang === "string" && AVAILABLE_LANGUAGES.includes(lang)
		? lang
		: DEFAULT_LANGUAGE;
};

クライアントサイド用

今回は useT というフックを作成し、その中で言語を決定し、その言語の t 関数を取得します。

src/hooks/useT.ts
"use client";
import { QUERY_NAME, getOptions, getSafeLang } from "@/locales/settings";
import i18next from "i18next";
import resourceToBackend from "i18next-resources-to-backend";
import { useSearchParams } from "next/navigation";
import { useEffect } from "react";
import { initReactI18next, useTranslation } from "react-i18next";

i18next
	.use(initReactI18next)
	.use(
		resourceToBackend(
			(lang: string, namespace: string) =>
				import(`@/locales/${lang}/${namespace}.json`),
		),
	)
	.init(getOptions());

export const useT = () => {
	const query = useSearchParams();
	const lang = getSafeLang(query.get(QUERY_NAME))

    // この `common` はネームスペース名です。今回は `common.json` なので `common` を設定
    // jsonファイルを増やし、ネームスペースを利用したい場合、`useT` に引数を設定しましょう
	const { t, i18n } = useTranslation("common");

	useEffect(() => {
		i18n.changeLanguage(lang);
	}, [lang, i18n]);

    return { t, i18n, lang };
};

サーバーサイド用

サーバーサイドで使うものをフックというのはどうなのかというのは置いといて

src/hooks/useServerT.ts
import { QUERY_NAME, getOptions, getSafeLang } from "@/locales/settings";
import { createInstance } from "i18next";
import resourcesToBackend from "i18next-resources-to-backend";
import { initReactI18next } from "react-i18next/initReactI18next";

// Nextjs で page.tsx の Props として受け取った searchParams の型情報
// [See](https://nextjs.org/docs/app/api-reference/functions/use-search-params#server-components)
export interface PageProps {
	searchParams: Promise<{
		[key: string]: string | string[] | undefined;
	}>;
}

export const useServerT = async (query: PageProps["searchParams"]) => {
	const lang = (await query)[QUERY_NAME];
	const resolvedLang = getSafeLang(lang);

	const i18nInstance = createInstance();
	await i18nInstance
		.use(initReactI18next)
		.use(
			resourcesToBackend(
				(lang: string, namespace: string) =>
					import(`@/locales/${lang}/${namespace}.json`),
			),
		)
		.init({
			...getOptions(),
			lng: resolvedLang,
		});

	return {
		t: i18nInstance.t,
		i18n: i18nInstance,
		lng: i18nInstance.language,
	};
};

翻訳を利用する

src/app/page.tsx
import { useT } from "@/hooks/useT.ts"; // クライアントサイドならこっち
import { useServerT } from "@/hooks/useServerT.ts"; // サーバーサイドならこっち

export default function Home() {
    const { t } = useT();

    return <h1>{t("title")}</h1> // "サンプルタイトル" か "Sample title" が言語設定に応じて表示されます
}

JSONファイル内でのネストにも対応しています。

src/locales/en/common.json
{
    "metadata": {
        "title": "Sample title",
        "description": "Sample description"
    }
}
{t("metadata.title")}
{t("metadata.description")}

動的な値を翻訳に利用することもできます。

src/locales/en/common.json
{
    "hi": "Hi! {{name}}!"
}
{t("hi", { name: "SUZUKI" })} // "Hi! SUZUKI!"

HTML タグを仕込むこともできます

src/locales/en/common.json
{
	// 1, 2 などのタグ名は任意 <hogehoge></hogehoge> とかでも可 
    "hi": "Hi! <1>{{firstLink}}</1> <2>{{secondLink}}</2>!"
}
import { Trans } from "react-i18next";

<Trans
	i18nKey="hi"
	values={{ firstLink: "first link", secondLink: "second link" }}
	components={{
		1: <Link href="https://example.com" />,
		2: <Link href="https://example.com" />,
	}}
/>
// first link と second link が Link に包まれてレンダリングされる

言語を変更する

ここでは言語を変更する関数のみを紹介します。
これをボタンなりセレクトメニューなりの onClick などによしなに渡してください。
実際のセレクトメニューでの実装例は このコミット にあります。

"use client";
import type { AVAILABLE_LANGUAGES, QUERY_NAME } from "@/locales/settings.json";
import { useRouter } from "next/navigation";

const changeLang = (newLang: AVAILABLE_LANGUAGES) => {
    const router = useRouter();

    const newParams = new URLSearchParams();
    params.set(QUERY_NAME, newLang);
    router.push(`?${params.toString()}`) // リロードなしで再レンダリングされる(たぶん)
}

[補足] サーバーサイドで Trans コンポーネントを使う方法

結論 TransWithoutContext コンポーネントを使用する

import { Trans as TransWithoutContext } from "react-i18next/TransWithoutContext";
// 使い方は前述の `Trans` と同じ

解説
src/context.js で createContext がトップレベルで呼ばれており、それが index.js に読み込まれているため、
import { Trans as TransWithoutContext } from "react-i18next"; すると context.js が読み込まれるためエラーが出ます。

さいごに

今回のプロジェクトはまだ開発中で説明など静的なページが全くなく、すべてが状態によって変化する動的なページでした。
そのため、クライアントコンポーネントのみのi18n対応だったので、今後サーバーコンポーネントでもi18n対応する必要があったら、追記しようと思います 追記しました。
よければ他の記事、PlaylistManager のスターもよろしくお願いします!
https://github.com/suzuki3jp/PlaylistManager
https://zenn.dev/suzuki3jp/articles/nextauth-approuter-google-20241025

GitHubで編集を提案

Discussion