🌏

Next.js App Routerでのi18n対応全部調べてみた

こんにちは、エンジニアの籏野です。
この度、Next.jsのApp Routerを利用したアプリケーションへのi18nの導入方法を調査することになりました。
Pages Routerを利用した場合の導入方法はイメージがつくのですがApp Routerを利用した場合は初めてとなりますので、その方法を調査・比較しました。

なお今回の調査は主に、Localizationの方法に焦点を当てています。
言語毎の出し分けのためのルーティング方法についてはそこまで解説しないのでご了承ください。

Next.jsの公式ドキュメントのinternationalizationページでは、いくつかLocalizationの方法が紹介されています。
このうち、react-i18n-routerはreact-i18nextと組み合わせて利用していること、またparaglide-nextはNext.jsとの連携が非推奨に見えることから以下の方法を調査していきます。

各手法の比較

言語ファイルについて

Minimal next-intl next-international react-i18next lingui
言語ファイルの管理方法 JSON/TS JSON/TS JSON/TS JSON .po/JSONが自動生成される
言語ファイルの分割読み込みが可能か
experimentalな機能で可能

機能面の比較

Minimal next-intl next-international react-i18next lingui
interpolation
pluralization
format
HTMLタグの埋め込み
コンポーネント外での利用

※ pluralizationは入力値に応じて単数形/複数形を切り替えるような機能です。

各手法の詳細

Minimalな方法

設定・導入方法

こちらは特にライブラリをインストールせずにLocalizationを実現する方法です。
JSONファイルで言語ファイルを用意し、指定した言語のJSONファイルをimportするというシンプルな方法です。

import "server-only";
// Localeは`"en" | "ja"`のような取り得る言語の型になっています
import type { Locale } from "@/i18n-config";

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();

利用方法

言語毎のテキストはJSONオブジェクトとして取得できますので、以下のように利用したいテキストを指定しコンポーネントに埋め込みます。
シンプルゆえにinterpolation等の機能はありませんが、最低限の機能は実現できています。

import type { Locale } from "../../../i18n-config";
import LocaleSwitcher from "../components/locale-switcher";
import Counter from "./components/counter";
import { getDictionary } from "./i18n/get-dictionary";

export default async function IndexPage(props: {
	params: Promise<{ lang: Locale }>;
}) {
	const { lang } = await props.params;

	const dictionary = await getDictionary(lang);

	return (
		<div>
			<div>
				<p>{dictionary["server-component"].welcome}</p>
				<Counter dictionary={dictionary.counter} />
			</div>
			<LocaleSwitcher />
		</div>
	);
}

next-intl

設定・導入方法

以下のコマンドにてnext-intlをインストールします。

pnpm add next-intl

Next.jsでの利用にあたっては以下のような設定ファイルが必要です。

import { hasLocale } from "next-intl";
import { getRequestConfig } from "next-intl/server";
import { routing } from "./routing";

export default getRequestConfig(async () => {
	// read from `cookies()`, `headers()`, etc.
	const locale = 'en';

	return {
		locale,
		messages: (await import(`./dictionaries/${locale}.json`)).default,
	};
});

この設定ファイルはnext.config.tsから読み込みます。

next.config.ts
import type { NextConfig } from "next";
import createNextIntlPlugin from 'next-intl/plugin';

const nextConfig: NextConfig = {
	/* config options here */
};

const withNextIntl = createNextIntlPlugin(
  "path-to-request-config"
);
export default withNextIntl(nextConfig);

TypeScript利用時は型が効くようにするのがよいでしょう

import type messages from "path-to-json";

declare module "next-intl" {
	interface AppConfig {
		Messages: typeof messages;
	}
}

またlayout.tsxにてNextIntlClientProviderを読み込む必要があります。

layout.tsx
import { NextIntlClientProvider } from "next-intl";

export default async function LocaleLayout({
	children,
}: {
	children: React.ReactNode;
}) {
	return <NextIntlClientProvider>{children}</NextIntlClientProvider>;
}

利用方法

サーバーコンポーネント/クライアントコンポーネントで利用方法が異なります。

サーバーコンポーネント

import { getTranslations } from "next-intl/server";


export default async function Page() {
	const t = await getTranslations("context");
	return (
		...
		<p>{t("welcome")}</p>
		...
	);
}

クライアントコンポーネント

"use client";

import { useTranslations } from "next-intl";

export default function ClientComponent() {
	const t = useTranslations("context");
	return (
		...
		<p>{t("welcome")}</p>
		...
	);
}

その他機能

interpolation
// "message": "Hello {name}!"
t('message', {name: 'Jane'}); // "Hello Jane!"
pluralization
// "message": "You have {count, plural, =0 {no followers yet} =1 {one follower} other {# followers}}."
t('message', {count: 0}); // "You have no followers yet"
t('message', {count: 1}); // "You have one follower"
t('message', {count: 3580}); // "You have 3,580 followers"
format

Intlオブジェクトを利用しているようです。

HTMLタグの埋め込み
// "message": "Please refer to <guidelines>the guidelines</guidelines>."
t.rich('message', {
	guidelines: (chunks) => <a href="/guidelines">{chunks}</a>
});
コンポーネント外での利用

サーバーサイドでのみ可能ですが、基本的には推奨していないようです。

https://next-intl.dev/docs/usage/messages#messages-outside-components

next-intl is heavily based on the useTranslations API which is intended to consume translations from within React components. This may seem like a limitation, but this is intentional and aims to encourage the use of proven patterns that avoid potential issues—especially if they are easy to overlook.

next-international

設定・導入方法

以下のコマンドにてnext-internationalをインストールします。

pnpm add next-international

JSONで言語ファイルを用意したら、クライアント/サーバーそれぞれのための設定ファイルを作成します。

client.ts
"use client";
import { createI18nClient } from "next-international/client";

export const { useI18n, useScopedI18n, I18nProviderClient } = createI18nClient({
	en: () => import("./dictionaries/en"),
	ja: () => import("./dictionaries/ja"),
});
server.ts
import { createI18nServer } from "next-international/server";

export const { getI18n, getScopedI18n, getStaticParams } = createI18nServer({
	en: () => import("./dictionaries/en"),
	ja: () => import("./dictionaries/ja"),
});

また、layout.tsxにてI18nProviderClientを読み込む必要があります。

layout.tsx
import type { ReactElement } from "react";
import { I18nProviderClient } from "./i18n/client";

export default async function Layout({
	params,
	children,
}: { params: Promise<{ lang: string }>; children: ReactElement }) {
	const { lang } = await params;

	return <I18nProviderClient locale={lang}>{children}</I18nProviderClient>;
}

利用方法

サーバーコンポーネント

import { getScopedI18n } from "./i18n/server";

export default async function Page() {
	const t = await getScopedI18n("server-component");

	return (
		...
		<p>{t("welcome")}</p>
		...
	);
}

クライアントコンポーネント

"use client";

import { useScopedI18n } from "./i18n/client";

export default function ClientComponent() {
	const t = useScopedI18n("context");
	return (
		...
		<p>{t("welcome")}</p>
		...
	);
}

その他機能

interpolation
// "message": "Hello {name}!"
t('message', {name: 'Jane'}); // "Hello Jane!"
pluralization

言語ファイルにて#zero#one等の条件を末尾に付けることで利用可能です

// 'cows#zero': 'No cows',
// 'cows#one': 'A cow',
// 'cows#other': '{count} cows'
t('cows', { count: 0 }) // "No cows"
t('cows', { count: 1 }) // "A cow"
t('cows', { count: 3 }) // "3 cows"
format

なし

HTMLタグの埋め込み

なし

コンポーネント外での利用

なし

react-i18next

設定・導入方法

以下のコマンドにて必要なライブラリをインストールします。

pnpm add react-i18next i18next

言語ファイル及び設定は他のライブラリと似たようなものを用意しますが、namespaceという概念があることが特徴です。
これにより、全ての言語ファイルを読み込まずに、必要なものだけを読み込むことができます。

import { type Resource, createInstance, type i18n } from "i18next";
import resourcesToBackend from "i18next-resources-to-backend";
import { initReactI18next } from "react-i18next/initReactI18next";
import { i18n as i18nConfig } from "@/i18n-config";

export default async function initTranslations(
	locale: string,
	namespaces: string[],
	i18nInstance?: i18n,
	resources?: Resource,
) {
	const instance = i18nInstance || createInstance();

	instance.use(initReactI18next);

	if (!resources) {
		instance.use(
			resourcesToBackend(
				(language: string, namespace: string) =>
					import(`./dictionaries/${language}/${namespace}.json`),
			),
		);
	}

	await instance.init({
		lng: locale,
		resources,
		fallbackLng: i18nConfig.defaultLocale,
		supportedLngs: i18nConfig.locales,
		defaultNS: namespaces[0],
		fallbackNS: namespaces[0],
		ns: namespaces,
		preload: resources ? [] : i18nConfig.locales,
	});

	return {
		i18n: instance,
		resources: { [locale]: instance.services.resourceStore.data[locale] },
		t: instance.t,
	};
}

また、TranslationsProviderを用意しlayout.tsxにて読み込む必要があります。

provider.tsx
"use client";

import { createInstance } from "i18next";
import { I18nextProvider } from "react-i18next";
import initTranslations from "./i18n/i18next";

export default function TranslationsProvider({
	children,
	locale,
	namespaces,
}: {
	children: React.ReactNode;
	locale: string;
	namespaces: string[];
}) {
	const i18n = createInstance();

	initTranslations(locale, namespaces, i18n);

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

layout.tsx
import type { ReactElement } from "react";
import TranslationsProvider from "./provider";

export default async function Layout({
	params,
	children,
}: { params: Promise<{ lang: string }>; children: ReactElement }) {
	const { lang } = await params;

	return (
		<TranslationsProvider
			locale={lang}
			namespaces={[...]}
		>
			{children}
		</TranslationsProvider>
	);
}

TypeScript利用時は型が効くようにするのがよいでしょう

declare module "i18next" {
	interface CustomTypeOptions {
		defaultNS: "default";
		resources: {
			// namespace毎の型を定義
		};
	}
}

利用方法

サーバーコンポーネント

import initTranslations from "./i18n/i18next";

export default async function Page() {
	const { t } = await initTranslations(lang, [...]);

	return (
		...
		<p>{t("welcome")}</p>
		...
	);
}

クライアントコンポーネント

"use client";

import { useTranslation } from "react-i18next";

export default function ClientComponent() {
	const { t } = useTranslation(...);
	return (
		...
		<p>{t("welcome")}</p>
		...
	);
}

その他機能

interpolation
// "message": "Hello {name}!"
t('message', {name: 'Jane'}); // "Hello Jane!"
pluralization

言語ファイルにて、_zero_one等の条件を末尾に付けることで利用可能です

// "key_one": "item"
// "key_other": "items"
t("key", { count: 1 }); // "item"
t("key", { count: 2 }); // "items"
format

Intlオブジェクトを利用しているようです。

HTMLタグの埋め込み

<Trans>コンポーネントを利用することで可能です。

<Trans i18nKey="userMessagesUnread" count={count}>
	Hello <strong title={t('nameTitle')}>{{name}}</strong>, you have {{count}} unread message. <Link to="/msgs">Go to messages</Link>.
</Trans>
コンポーネント外での利用

i18nインスタンスを直接扱えばいいようです。

import i18n from "./i18n/i18next";

i18n.t("key", { count: 1 });

lingui

設定・導入方法

以下のコマンドで必要なライブラリをインストールします。

pnpm add @lingui/core @lingui/react
pnpm add -D @lingui/cli @lingui/swc-plugin

CLI用に設定ファイルを用意します。

lingui.config.ts
import { defineConfig } from "@lingui/cli";
import { formatter } from '@lingui/format-json';
import {i18n} from "./src/i18n-config"

export default defineConfig({
	sourceLocale: i18n.defaultLocale,
	locales: [...i18n.locales],
	catalogs: [
		{
			path: "<rootDir>/src/...",
			include: ["src"],
		},
	],
	fallbackLocales: {
		default: i18n.defaultLocale,
	},
});

package.jsonにCLI用のスクリプトを追加します。

{
	"scripts": {
		"lingui:extract": "lingui extract --clean",
		"lingui:compile": "lingui compile --typescript"
	}
}

また、next.config.tsにてswc-pluginを読み込む必要があります。

next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
	experimental: {
		swcPlugins: [["@lingui/swc-plugin", {}]],
	},
};

export default nextConfig

また言語ファイルを読み込むための設定ファイルを用意する必要があります。

appRouterI18n.ts
import "server-only";

import { type I18n, type Messages, setupI18n } from "@lingui/core";
import linguiConfig from "@/lingui.config";

const { locales } = linguiConfig;
// optionally use a stricter union type
type SupportedLocales = string;

async function loadCatalog(locale: SupportedLocales): Promise<{
	[k: string]: Messages;
}> {
	const { messages } = await import(
		`./i18n/dictionaries/${locale}/messages`
	);
	return {
		[locale]: messages,
	};
}
const catalogs = await Promise.all(locales.map(loadCatalog));

// transform array of catalogs into a single object
export const allMessages = catalogs.reduce((acc, oneCatalog) => {
	return { ...acc, ...oneCatalog };
}, {});

type AllI18nInstances = { [K in SupportedLocales]: I18n };

export const allI18nInstances: AllI18nInstances = locales.reduce(
	(acc, locale) => {
		const messages = allMessages[locale] ?? {};
		const i18n = setupI18n({
			locale,
			messages: { [locale]: messages },
		});
		return { ...acc, [locale]: i18n };
	},
	{},
);

export const getI18nInstance = (locale: SupportedLocales): I18n => {
	if (!allI18nInstances[locale]) {
		console.warn(`No i18n instance found for locale "${locale}"`);
	}
	return allI18nInstances[locale]! || allI18nInstances["en"]!;
};

クライアント用のproviderを用意し、上記の設定をlayout.tsx/page.tsx双方で読み込む必要があります。

provider.tsx
"use client";

import { type Messages, setupI18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { useState } from "react";

export function LinguiClientProvider({
	children,
	initialLocale,
	initialMessages,
}: {
	children: React.ReactNode;
	initialLocale: string;
	initialMessages: Messages;
}) {
	const [i18n] = useState(() => {
		return setupI18n({
			locale: initialLocale,
			messages: { [initialLocale]: initialMessages },
		});
	});
	return <I18nProvider i18n={i18n}>{children}</I18nProvider>;
}
layout.tsx
import { setI18n } from "@lingui/react/server";
import { getI18nInstance } from "./appRouterI18n";
import { LinguiClientProvider } from "./provider";

type Props = {
	params: Promise<{
		lang: string;
	}>;
	children: React.ReactNode;
};

export default async function RootLayout({ params, children }: Props) {
	const { lang } = await params; // get the language from the params
	const i18n = getI18nInstance(lang); // get a ready-made i18n instance for the given locale
	setI18n(i18n); // make it available server-side for the current request

	return (
		<LinguiClientProvider initialLocale={lang} initialMessages={i18n.messages}>
			{children}
		</LinguiClientProvider>
	);
}
page.tsx
import { setI18n } from "@lingui/react/server";
import { getI18nInstance } from "./appRouterI18n";

export default async function IndexPage({
	params,
}: {
	params: Promise<{
		lang: string;
	}>;
}) {
	const { lang } = await params; // get the language from the params
	const i18n = getI18nInstance(lang); // get a ready-made i18n instance for the given locale
	setI18n(i18n); // make it available server-side for the current request
	return (
		...
	);
}

利用方法

翻訳作業の大まかな流れは以下のようになります。

  • 翻訳したい箇所を<Trans>コンポーネントで囲む
<Trans>Welcome</Trans>
  • pnpm run lingui:extractを実行し、翻訳箇所を言語ファイルに書き出す。
  • 翻訳した内容を言語ファイルに記載する。
  • pnpm run lingui:compileを実行し、言語ファイルをコンパイルする。
    • コンパイルしたファイルはtsファイルとして出力されるので、これをappRouterI18n.tsが読み込む

他のライブラリが先に言語ファイルを用意する必要があるのに対し、linguiは言語ファイルがソースコードから自動で生成されるというのが大きな違いです。

その他機能

<Trans>コンポーネントを利用していると、以下の機能は不要なように感じました。
そこで以下の機能をコンポーネント外で利用する方法をまとめます。

interpolation

コンポーネントの場合は、通常のコンポーネントと同様に値を埋めこんでいきます。

<Trans>Hello {name}!</Trans>

コンポーネント外では、テンプレートリテラルのように埋め込むことができます。

import { t } from "@lingui/core/macro";

const name = "Tom";

t`My name is ${name}`;
t({ id: "msg.name", message: `My name is ${name}` });
pluralization

Pluralコンポーネントもしくはpluralマクロを使います

import { Plural } from "@lingui/react/macro";
<Plural value={numBooks} one="Book" other="Books" />;
import { plural } from "@lingui/core/macro";
const message = plural(numBooks, {
	one: "# book",
	other: "# books",
});
format

Intlオブジェクトを利用しているようです。

HTMLタグの埋め込み

<Trans>コンポーネントを利用することで通常のReactコンポーネントと同じ感覚で利用可能です。

コンポーネント外での利用

@lingui/core/macroのt関数を利用します。

t`Message`;

t({
	id: "custom.id",
	message: "Message with custom ID",
});

まとめ

今回の記事では、App Routerで利用可能なi18nライブラリを紹介しました。
linguiはマクロを用いた言語ファイルの自動生成を行っており、他のライブラリとは大きく異なるのが印象的でした。
今回の調査結果が皆さんのライブラリ選定の参考になれば幸いです。

この記事を書いた人

籏野 拓
2018年新卒入社

FORCIA Tech Blog

Discussion