Next.jsのApp Routerでi18n（国際化）対応

# はじめに

こんにちは、Gakken LEAPのフロントエンドエンジニアの Okuma です。

今回はNext.jsのApp Routerでi18n（国際化）の対応をしてみたので、その際の手順やtipsについてご紹介できればと思います。

i18nとは、Internationalizationの略でソフトウェアや製品をさまざまな言語や地域に適用させることを意味しており、これには、テキストの翻訳、通貨や日付の表示形式の調整、文化的な違いを考慮したインターフェースの設計などが含まれるそうです。i18n（国際化）の対応を行うことで、世界中のユーザーが母国語もしくは共通語でソフトウェアや製品にアクセスして利用することができるようになります。

読み方は「アイじゅうはちエヌ」です。

Next.jsではデフォルトでApp RouterでもPage Routerでもi18nがビルトインでサポートされています。（[公式ドキュメント](https://nextjs.org/docs/app/building-your-application/routing/internationalization)）

ただ、今回はデフォルトロケールについてはパスにロケールを含めたくなかったため、簡単に実装できる [next-intl](https://next-intl-docs.vercel.app/) を使用して実装してみました。
同じようにi18nの実装で困っている方の助けになればと思います！

# セットアップするにあたっての注意点

公式ドキュメントの[Get Startedのページ](https://next-intl-docs.vercel.app/docs/getting-started/app-router)を覗いてみると、App RouterとPage Router、さらにApp Routerの中でも「With i18n routing」と「Without i18n routing」があります。
「Without i18n routing」で実装してしまうと単一の言語のみの対応となってしまうので、複数言語に対応したい場合は「With i18n routing」を選択しましょう。

![Screenshot 2024-12-09 at 9.27.44.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3492732/5f6a08e1-22d9-e3fc-8abc-aca88052d5d1.png)

今回はApp Routerかつ、複数言語（日本語、英語）に対応したいと思っているので、「With i18n routing」の手順に沿って進めていきます！

# セットアップ手順

基本的には公式ページの手順で問題ありませんが、日本語ドキュメントがありませんのである程度噛み砕いた形にして、さらに詰まったポイントについても解説を加えてご紹介できればと思います。

App Routerのプロジェクトがない場合は、以下のコマンドで生成できます。

```bash
npx create-next-app@latest
```

すでにプロジェクトがある場合は以下のコマンドで`next-intl`をインストールします。

```bash
npm install next-intl
```

最終的なディレクトリ・ファイル構造は以下のようになります。

```plaintext
├── messages
│   ├── en.json (1)
│   └── ja.json (1)
├── next.config.mjs (2)
└── src
    ├── i18n
    │   ├── routing.ts (3)
    │   └── request.ts (5)
    ├── middleware.ts (4)
    └── app
        └── [locale]
            ├── layout.tsx (6)
            └── page.tsx (7)
```

既存のApp Routerのディレクトリ構造がある場合は、`[locale]`配下にページファイルがすべて収まるように移動させます。

## (1) `messages/en.json`

`messages`ディレクトリ以下に翻訳したい言語ごとのJSONファイルを配置します。もちろんリモートのデータソースから読み込むこともできますが、今回はローカルで読み込む一番簡単な方法で進めます。
複数言語に対応したい場合は、その言語の数だけJSONファイルが必要になります。

```json:messages/en.json
{
  "home": {
    "title": "Hello World!",
    "about": "Go to the about page"
  }
}
```

```json:messages/ja.json
{
  "home": {
    "title": "こんにちは、世界！",
    "about": "aboutページへ行く"
  }
}
```

言語別のJSONファイルを用意したとしても、キーはすべて揃えなければなりません。各言語ファイルの同じキーにある文字列を引っ張ってきて、表示を切り替えることになります。

## (2) `next.config.js`

サーバーコンポーネントにリクエスト固有のi18nを提供するためのエイリアスを作成するプラグインを設定します。

```js:next.config.js
const createNextIntlPlugin = require('next-intl/plugin');

const withNextIntl = createNextIntlPlugin();

/** @type {import('next').NextConfig} */
const nextConfig = {};

module.exports = withNextIntl(nextConfig);
```

すでに`next.config.js`で何かしらの設定を記載している場合は、`const nextConfig = {}`の中に移行すれば問題ありません。

## (3) `src/i18n/routing.ts`

次の2つの箇所でルーティングを統合します。

1. ミドルウェア：ロケールを判定し、リダイレクト・リライトを処理する（例. `/` → `/en`）
2. ナビゲーションAPI：Next.jsのナビゲーションAPIの軽量ラッパー `<Link />`

これによって`/about`のようなパスの操作ができるようになります。言語プレフィックスなどのi18nの機能は裏側で処理されます。

`routing.ts`を作成して、この2つの場所で設定を共有します。

```ts:src/i18n/routing.ts
import { defineRouting } from 'next-intl/routing';
import { createNavigation } from 'next-intl/navigation';

export const routing = defineRouting({
  // A list of all locales that are supported
  locales: ['en', 'ja'],

  // Used when no locale matches
  defaultLocale: 'en'
});

// Lightweight wrappers around Next.js' navigation APIs
// that will consider the routing configuration
export const {Link, redirect, usePathname, useRouter, getPathname} =
  createNavigation(routing);
```

## (4) `src/middleware.ts`

先ほど作成したルーティング設定をミドルウェアで使用していきます。

```ts:src/middleware.ts
import createMiddleware from 'next-intl/middleware';
import { routing } from './i18n/routing';

export default createMiddleware(routing);

export const config = {
  // Match only internationalized pathnames
  matcher: ['/', '/(de|en)/:path*']
};
```

## (5) `src/i18n/request.ts`

サーバーコンポーネントで`next-intl`の機能を使用する場合、関連する設定は`i18n/request.ts`に配置されている中央モジュールから読み取られます。この設定のスコープは現在のリクエストに限定されており、ユーザーのロケールに基づいてメッセージやその他のオプションを提供するために使用できます。

```ts:src/i18n/request.ts
import { getRequestConfig } from 'next-intl/server';
import { routing } from './routing';

export default getRequestConfig(async ({requestLocale}) => {
  // This typically corresponds to the `[locale]` segment
  let locale = await requestLocale;

  // Ensure that a valid locale is used
  if (!locale || !routing.locales.includes(locale as any)) {
    locale = routing.defaultLocale;
  }

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

## (6) `src/app/[locale]/layout.tsx`

ミドルウェアによって一致したロケールは、`locale`パラメータを介して利用でき、それをドキュメント言語として使用できます。さらに`NextIntlClientProvider`でラップすることで、`i18n/request.ts`からクライアントコンポーネントに設定を渡すことができるようになります。
ここまで設定すると、サーバーコンポーネントでもクライアントコンポーネントでもi18nの機能を使えるようになります。

```tsx:app/[locale]/layout.tsx
import { NextIntlClientProvider } from 'next-intl';
import { getMessages } from 'next-intl/server';
import { notFound } from 'next/navigation';
import { routing } from '@/i18n/routing';

export default async function LocaleLayout({
  children,
  params: {locale}
}: {
  children: React.ReactNode;
  params: {locale: string};
}) {
  // Ensure that the incoming `locale` is valid
  if (!routing.locales.includes(locale as any)) {
    notFound();
  }

  // Providing all messages to the client
  // side is the easiest way to get started
  const messages = await getMessages();

  return (
    <html lang={locale}>
      <body>
        <NextIntlClientProvider messages={messages}>
          {children}
        </NextIntlClientProvider>
      </body>
    </html>
  );
}
```

## (7) `src/app/[locale]/page.tsx`

ここまで実装できれば各ページやコンポーネントで`next-intl`の機能を使って翻訳することができます。

```tsx:app/[locale]/page.tsx
import { useTranslations } from 'next-intl';
import { Link } from '@/i18n/routing';

export default function HomePage() {
  const t = useTranslations('home');
  return (
    <div>
      <h1>{t('title')}</h1>
      <Link href="/about">{t('about')}</Link>
    </div>
  );
}
```

# 実装の注意点やTips

いくつか実装した際に詰まったポイントや、取り入れて良かった設定などをご紹介します。

## デフォルトロケールの取り扱い

実はi18nの機能はNext.jsの標準でも搭載されていますが、今回`next-intl`を選んだのはデフォルトロケールの取り扱い方が簡単だったためです。
というのは、ここまでの実装だとパスにロケールが入った状態がデフォルトになります。ルートであれば、`/en/`、`/ja/`になります。
私の場合は英語をデフォルトにし、さらにプレフィックスを付けたくなかったので、ロケールが`en`の場合は`/`にして、日本語の場合のみパスに`/ja/`挟む形にしたかったです。

設定自体は簡単で、`src/i18n/routing.ts`を以下のようにします。

```ts:src/i18n/routing.ts
import { createNavigation } from 'next-intl/navigation';
import { defineRouting } from 'next-intl/routing';

export const routing = defineRouting({
  locales: ['en', 'ja'],


  defaultLocale: 'en',
  // ↓この設定
  localePrefix: 'as-needed',
});

export const { Link, redirect, usePathname, useRouter } = createNavigation(routing);
```

これで`/`（英語）と`/ja`（日本語）のルーティングが可能になります。
設定値のデフォルトが`always`ですが、これにするとプレフィックスが毎回付くようになります。

## middlewareに既存実装がある場合

手順の中で`src/middleware.ts`の実装がありましたが、これは新規実装向けになっています。私が`next-intl`を導入したのはすでに既存実装がある状態でした。その場合は手順通りだとうまくいかないことがあるので、以下を参考にしました。
[Composing other middleware](https://next-intl.dev/docs/routing/middleware#composing-other-middlewares)

```ts:src/middleware/ts
import createMiddleware from 'next-intl/middleware';
import { NextRequest } from 'next/server';

export default async function middleware(request: NextRequest) {
  // Step 1: Use the incoming request (example)
  const defaultLocale = request.headers.get('x-your-custom-locale') || 'en';

  // Step 2: Create and call the next-intl middleware (example)
  const handleI18nRouting = createMiddleware({
    locales: ['en', 'de'],
    defaultLocale
  });
  const response = handleI18nRouting(request);

  // Step 3: Alter the response (example)
  response.headers.set('x-your-custom-locale', defaultLocale);

  return response;
}

...
```

この実装ではNext.jsの標準の`middleware`関数を使用しています。私の場合ミドルウェアでカスタムヘッダの追加などを行っていたので、`next-intl`の`createMiddleware`との統合が必須でした。

その場合、`middleware`メソッド内で`createMiddleware`を呼び出し、一旦変数に格納します。そして引数の`request`をラップしてこれを返してあげることで、標準の`middleware`メソッドと統合しても`next-intl`のミドルウェアとしても機能するようになります。

# おわりに

今回は`next-intl`を使ったApp Routerでのi18n（国際化）対応について、最短実装の手順と注意点・Tipsについて簡単に紹介させていただきました。

現在動いているプロジェクトではPage Routerでi18nを対応しているため、今後App Routerへの移行などの際に参考にしたいと思います。

# エンジニア募集中

Gakken LEAP では教育をアップデートしていきたいエンジニアを絶賛大募集しています！！
ぜひお気軽にカジュアル面談へお越しください！！

https://gakken-leap.co.jp/recruit/

# 参考

- [fluid-tailwind](https://fluid.tw/)
- [【CSS】marginやpadding、font-sizeにも最大値や最小値を設定する方法](https://groworks.jp/blog/5192)