Next.js で i18n

## i18n とは

ここでは、アプリケーションの国際化対応を行うことを言う。
国際化対応としては、**言語を国や地域に応じて表示を出し分ける**ものがまずは思いつくが、本記事でもそれを対象としている。
一般的には、そのほかにも日時フォーマットやテキストの方向、測定単位なども i18n の対象範囲として挙げられるようだ。[^1]
以下では、「i18n=多言語対応」として、Next.js アプリケーションの i18n を行うための進め方についてお話させていただく。

[^1]:https://developer.mozilla.org/ja/docs/Glossary/I18N

## 前提

- react: 18.2.0
- next: 12.1.6

## 前準備

Next.js で i18n を実現するためには、いくつかの設定値の変更や追加の設定コードが必要になる。
順を追って紹介する。

### 必要パッケージのインストール

```sh
npm install next-i18next
...
npm list next-i18next
#next-i18next@13.2.2
```

### i18n の基本設定

`next-i18next.config.js` というファイルを作成し

```sh
touch next-i18next.config.js
```

以下のような記述をする。

```next-i18next.config.js
/** @type {import('next-i18next').UserConfig} */
module.exports = {
  i18n: {
    defaultLocale: 'ja',
    locales: ['ja', 'en']
  }
};
```

この設定では、デフォルトの言語設定(以下**ロケール**)を `ja`(日本語)としている。
また、選択可能なロケールとして、`ja` 及び `en`(英語)を設定している。

次に、Next.js の設定ファイルに上の設定値を読み込むように以下の追記する。

```next.config.js
const { i18n } = require('./next-i18next.config')

module.exports = {
  i18n,
}
```

:::note info
ちなみに、今回は i18n 設定用ファイルを新規に作成したが、ファイルを分けずにこれらの内容を直接 next.config.js に入れ込んでも問題ない模様。
:::

### _app.tsx の修正

あまり深く考えずに HOC で囲むらしい。

```_app.tsx
import { appWithTranslation } from 'next-i18next'

const MyApp = ({ Component, pageProps }) => (
  <Component {...pageProps} />
)

export default appWithTranslation(MyApp)
```

前準備は以上となる。
以降では、翻訳テキストを表示する部分の処理を書いていく。

## 翻訳

以下のようなディレクトリ構成を想定する。(関連箇所のみ抜粋)

```sh
src/
├── components
│   └── Hello.tsx
└── pages
    ├── _app.tsx
    └── hello.tsx
```

### 基本編

#### 翻訳テキストの用意

翻訳対象としたいテキストデータは、デフォルトでは、`/public/locales/{locale}/**` に格納する必要がある。
また、データは json 形式となる。
今回は ja および en のロケール選択を可能にしているので、それぞれのディレクトリを作成する。

```sh
mkdir public/locales
mkdir public/locales/ja
mkdir public/locales/en
```

:::note info
翻訳テキストデータの格納場所は、`next-i18next.config.js` の設定オプション `localePath` で変更可能である。
:::


次に、各ロケールのディレクトリ配下に、`common.json` と言うファイルを作成する。

```sh
touch public/locales/ja/common.json
touch public/locales/en/common.json
```

作成したファイルに、key:value の形式で、翻訳テキストを書く。
今回は、日本語をキーとして、ロケールが ja の場合は、そのまま日本語を、en の場合は英語の翻訳が表示されるようにしている。

```ja/common.json
{
  "こんにちは": "こんにちは"
}
```

```en/common.json
{
  "こんにちは": "Hello"
}
```

:::note info
`common` は、デフォルトのネームスペース名となる。
`next-i18next.config.js` の設定オプション `defaultNS` で変更可能である。
また、ネームスペースについては[後述する](#ネームスペースとは)。
:::

#### テキストを表示させる

`useTranslation` というフックを使用して、以下のようなコードを書く。

```src/components/Hello.tsx
import { useTranslation } from 'next-i18next';
import React from 'react';

export const Hello = () => {
  const { t } = useTranslation();
  return <p>{t('こんにちは')}</p>;
};
```

```src/pages/index.tsx
import React from 'react';
import { Hello } from '../../components/Hello';

const Index = () => {
  return <Hello />;
};
export default Index;
```

`next dev` すると、画面には、「こんにちは」と表示された。

#### ロケールの切り替え

このままでは日本語から離れられないので、ロケールの切り替えを考える。
以下の追記を行う。

```diff_tsx:src/pages/index.tsx
+ import { GetServerSideProps } from 'next';
+ import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
import React from 'react';
import { Hello } from '../../components/Hello';

const Index = () => {
  return <Hello />;
};
export default Index;

+ export const getServerSideProps: GetServerSideProps = async (context) => {
+   const locale = 'en';
+   return { props: { ...(await serverSideTranslations(locale)) } };
+ };
```

`getServerSideProps` は SSR の関数である。この中でロケールの指定(locale=en)を行い、それを `serverSideTranslations` に渡す。これにより、**この pages/index.tsx 配下で呼ばれるコンポーネント内では、英語の翻訳が適用されるようになる。**

実際に、画面を確認すると、今度は「Hello」と表示されているはずだ。

#### ネームスペースとは？

デフォルトネームスペースが `common` であると上述した。ネームスペースは、翻訳テキストを任意の粒度で管理することができる機能である(と個人的に理解している)。翻訳ファイル `public/locales/<locale>/**` 配下のディレクトリ構成がそのままネームスペースとなる。そして、**翻訳テキストを参照するときにネームスペースを指定する必要があり、未指定の場合はデフォルトの値(`common`)が採用される。** 上の例では、「こんにちは」と言う翻訳に関するデータは `common` ネームスペースに入れているので、ネームスペースの指定を行う必要がなかったのである。

#### 他のネームスペースの利用

`common` 以外のネームスペース(`foo`)を利用してみよう。
以下のファイルを作成する。(ja の場合も同様のため、割愛)

```sh
touch public/locales/en/foo.json
```

```public/locales/en/foo.json
{
  "ありがとう": "Thank you"
}
```

以下の追記を行う。

```diff_tsx:src/pages/index.tsx
import { GetServerSideProps } from 'next';
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
import React from 'react';
import { Hello } from '../../components/Hello';

const Index = () => {
  return <Hello />;
};
export default Index;

export const getServerSideProps: GetServerSideProps = async (context) => {
  const locale = 'en';
-  return { props: { ...(await serverSideTranslations(locale)) } };
+  return { props: { ...(await serverSideTranslations(locale, ['foo'])) } };
};
```

`serverSideTranslations` の第二引数にネームスペースの(配列で)与えた。

さらに、

```diff_tsx:src/components/Hello.tsx
import { useTranslation } from 'next-i18next';
import React from 'react';

export const Hello = () => {
-  const { t } = useTranslation();
+  const { t } = useTranslation('foo');
  return <p>{t('ありがとう')}</p>;
};
```

こうすることで、デフォルトネームスペース以外の翻訳テキストについても表示できるようになった。

### 動的なロケールの変更

実際のアプリケーションでは、ロケールは動的に変更できるべきである。
Next.js では簡単に実現できるようになっている。
以下は、ロケールを切り替えるコンポーネントの一例である。
なお、Next.js のロケール処理の詳細は[こちら](https://nextjs.org/docs/pages/building-your-application/routing/internationalization)を参照されたし。

```components/LocaleSwitcher.tsx
import Link from 'next/link';
import { useRouter } from 'next/router';

export const LocaleSwitcher = ({ href }: { href: string }) => {
  const router = useRouter();
  const locale = getLocale(router.locale);
  const switchedLocale = locale === 'ja' ? 'en' : 'ja';

  return (
    <Link href={href} locale={switchedLocale}>
      {`言語を ${switchedLocale} に変更する`}
    </Link>
  );
};
```

翻訳テキストの側にロケール切り替え用コンポーネントを配置する。

```diff_tsx:src/pages/index.tsx
import { GetServerSideProps } from 'next';
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
import React from 'react';
import { Hello } from '../../components/Hello';
+ import { LocaleSwitcher } from '../components/LocaleSwitcher';

const Index = () => {
+  const router = useRouter();
  return (
    <>
+      <LanguageSwitcher href={router.asPath} />
      <Hello />
    </>
  );
};
export default Index;

export const getServerSideProps: GetServerSideProps = async (context) => {
-  const locale = 'en';
+  const locale = context.locale || 'ja';// context.locale が undefined の可能性があるため
  return { props: { ...(await serverSideTranslations(locale, ['foo'])) } };
};
```

リンクをクリックすることで、ja <-> en の切り替えが可能になった。
また、この時のページ URL はそれぞれ `{HOST}/` および `{HOST}/en` となっている。

<img width="200" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/671630/e7ea02ac-13c3-ed8b-3f7d-43d229cadcdf.png">


<img width="200" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/671630/fa7101dc-bbb4-03df-8779-873073c42a68.png">


### 応用編

以上で大体の i18n の仕組みを説明した。
ここからは、少し複雑なパターンについて少し紹介する。

#### ネストされたネームスペースの利用

`public/locales/{en,ja}/bar/baz.json` にある翻訳テキストを対象とする場合を考える。
**階層は、スラッシュによる区切りで表現する。**

```sh
public/locales
├── en
│   ├── common.json
│   ├── foo.json
│   └── bar
│       └── baz.json
└── ja
    ├── common.json
    ├── foo.json
    └── bar
        └── baz.json
```

```en/bar/baz.json
{
  "さようなら": "Good-bye"
}
```

```ja/bar/baz.json
{
  "さようなら": "さようなら"
}
```

変更箇所のみ抜粋

```diff_tsx:src/pages/index.tsx
...

export const getServerSideProps: GetServerSideProps = async (context) => {
  const locale = context.locale || 'ja';
-  return { props: { ...(await serverSideTranslations(locale, ['foo'])) } };
+  return { props: { ...(await serverSideTranslations(locale, ['bar/baz'])) } };
};
```

```diff_tsx:src/components/Hello.tsx
...
export const Hello = () => {
-  const { t } = useTranslation('foo');
+  const { t } = useTranslation('bar/baz');
  return <p>{t('さようなら')}</p>;
};
```

#### 複数ネームスペースの利用

`serverSideTranslations` および `useTranslation` にネームスペースの配列を渡す。
さらに、`t()`で翻訳キーを指定する際、**useTranslation で配列の二番目以降に指定したネームスペースに属するキーに関しては、`ns: <namespace>` でネームスペースを指定する必要がある。**

変更箇所のみ抜粋

```diff_tsx:src/pages/index.tsx
...

export const getServerSideProps: GetServerSideProps = async (context) => {
  const locale = context.locale || 'ja';
-  return { props: { ...(await serverSideTranslations(locale, ['bar/baz'])) } };
+  return { props: { ...(await serverSideTranslations(locale, ['foo', 'bar/baz'])) } };
};
```

```diff_tsx:src/components/Hello.tsx
...
export const Hello = () => {
-  const { t } = useTranslation('foo');
+  const { t } = useTranslation(['foo', 'bar/baz']);
  return (
    <p>
-      {t('さようなら')}
+      {t('ありがとう')}/{t('さようなら', { ns: 'bar/baz' })}
    </p>
  );
};
```

この例では、階層立ったネームスペースが二番目以降に指定されるという合わせ技になっている。

#### おまけ

このような状態で `common` の翻訳テキスト扱いはどうなるかだが、`serverSideTranslations` では指定が必要であり、`useTranslation` では指定が不要である。そして、`t()`で翻訳キーを指定するときに `ns: 'common'` をそっと添えてあげる。

```diff_tsx:src/components/Hello.tsx
...
export const Hello = () => {
  const { t } = useTranslation(['foo', 'bar/baz']);
  return (
    <p>
-      {t('ありがとう')}/{t('さようなら', { ns: 'bar/baz' })}
+      {t('ありがとう')}/{t('さようなら', { ns: 'bar/baz' })}/{t('こんにちは', { ns: 'common' })}
    </p>
  );
};
```

## Dockerfile への記述

Docker イメージにする場合は、config ファイル2つを WORKDIR に追加する必要がある。

```diff_docker:Dockerfile
FROM registry.access.redhat.com/ubi8/nodejs-16-minimal:1
WORKDIR /opt/app-root/src
...
+ COPY next-i18next.config.js /opt/app-root/src/
+ COPY next.config.js /opt/app-root/src/
...
CMD ["npm", "start"]
```

## 参考

https://github.com/i18next/next-i18next