Next.js App Router での i18n 対応例
概要
色々あって参画している案件で Next.js の App Router への移行を主導する立場になりました。
なかでも i18n 対応が結構骨が折れた印象でした。
- いろんな記事を見たが Server Component (以下、SC) に対応しているものが見当たらなかった
- Root layout にて Provider でラップするとかはあった
- けどそれだと Client Component (以下、CC) になるのでは(実際にハンズオンで確認まではしてないけど)
- Root layout にて Provider でラップするとかはあった
-
Next.js の公式ドキュメントで SC の対応例は掲載されていた
- ただ、 スクラッチ実装なりの問題点はある[1]
-
react-i18next
とかnext-i18next
を使った方がインターフェースの統一性が取れる- デファクトなパッケージであれば使用経験者も多い
- i18nライブラリは複数形や文脈に応じた翻訳、フォールバック言語、カスタムフォーマッタなど翻訳に関する高度な機能を備えていることが多く、それと同等の機能をスクラッチ実装するのは非現実的である
-
- ただ、 スクラッチ実装なりの問題点はある[1]
以下の記事が一番参考になリましたが、 TypeScript を使っていなかったり、自分のプロダクトで実現したい仕様とは少し違っていたりしました。
このような経緯から、試験的にこの記事に記載した手段で実装することにしました。
最終的な実装は以下のリポジトリにあります。
動作環境
ランタイム/パッケージ名 | バージョン |
---|---|
Node.js | 20.10.0 |
Next.js | 14.1 |
i18next | 23.8.2 |
next-i18next | 15.2.0 |
react-i18next | 14.0.5 |
negotiator | 0.6.3 |
以降、実際にコードを書いていきます。
リポジトリ生成
便宜上、ESLint や Prettier がセットアップされているリポジトリを雛形として使います。
$ npx create-next-app@latest nextjs-app-router-i18n-example --use-npm --example "https://github.com/k0kishima/nextjs-scaffold"
$ cd nextjs-app-router-i18n-example/
$ npm i
$ npx husky install
i18n関連のパッケージのインストール
$ npm i next-i18next react-i18next i18next i18next-resources-to-backend negotiator
$ npm i --save-dev @types/negotiator
Dynamic Route の作成
Dynamic Routes は App Router の機能で [lang]
のように角括弧で動的なセグメントを表現することができます。
今回は以下のような手順で、app
ディレクトリの直下に [lang]
というセグメントを追加しました。[2]
$ cd src/app
$ mkdir '[lang]'
$ mv favicon.ico globals.css layout.tsx page.tsx '[lang]'
$ cd ../../
$ tree src/app
src/app
└── [lang]
├── favicon.ico
├── globals.css
├── layout.tsx
└── page.tsx
2 directories, 4 files
[locale]
でもいいと思ったんですが以下の理由で [lang]
にしました。
- Next.js 公式ドキュメントでは lang が採用されていたり、
<html>
の lang 属性とのメンタルモデルの一致もある - locale の方は言語だけでなく、地域や文化に特有のフォーマット(通貨、日付形式、数値形式、文字の並び替え順序など)を含む lang より大きな概念になる
ここまでの手順で、以下のようにサーバーを起動しても http://localhost:3000/
ではアクセスできなくなっているはずです。
$ npm run dev
一方、 http://localhost:3000/ja
や http://localhost:3000/en
であれば正常にレスポンスが得られると思います。
ページの簡素化
現在は Next.js のデフォルトのページが配置してあるので、最低限 i18n 関連の動作確認をできるように簡素化します。
トップページを以下に置き換えます。
export default async function Home({ params }: { params: { lang: string } }) {
const lang = params.lang;
return (
<main>
<div className="m-5">{lang}</div>
</main>
);
}
グローバルなスタイル定義からも余計なものを消すため以下の内容に置き換えます。
@tailwind base;
@tailwind components;
@tailwind utilities;
ルートレイアウトでもルーティング設定で取れるようになった lang
パラメーターを HTML に設定するように修正します。
+import { dir } from 'i18next';
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import '@/app/globals.css';
@@ -11,11 +12,13 @@ export const metadata: Metadata = {
export default function RootLayout({
children,
+ params: { lang },
}: Readonly<{
children: React.ReactNode;
+ params: { lang: string };
}>) {
return (
- <html lang="en">
+ <html lang={lang} dir={dir(lang)}>
<body className={inter.className}>{children}</body>
</html>
);
ここまでの手順で、 http://localhost:3000/en
にアクセスすると以下のようになります。
[lang]
に相当する部分をパラメータとして取得した結果を画面に出力しているので、 http://localhost:3000/ja
などURLを変更すると出力も変わります。
ミドルウェアの実装
続いてミドルウェアを実装します。
まず i18n 関連の設定を保持するモジュールを作成します。
$ mkdir src/app/i18n/
ここでデフォルトの言語と、利用可能とする言語をそれぞれ指定します。
export const defaultLanguage = 'ja';
export const availableLanguages = [defaultLanguage, 'en'];
続いて、 app
と同じ階層に ミドルウェアを設置します。
import { NextRequest, NextResponse } from 'next/server';
import Negotiator from 'negotiator';
import { defaultLanguage, availableLanguages } from '@/app/i18n/settings';
const getNegotiatedLanguage = (
headers: Negotiator.Headers,
): string | undefined => {
return new Negotiator({ headers }).language([...availableLanguages]);
};
export const config = {
// https://nextjs.org/docs/app/building-your-application/routing/middleware#matcher
matcher: ['/((?!api|_next/static|_next/image|.*\\.png$).*)'],
};
export function middleware(request: NextRequest) {
const headers = {
'accept-language': request.headers.get('accept-language') ?? '',
};
const preferredLanguage = getNegotiatedLanguage(headers) || defaultLanguage;
const pathname = request.nextUrl.pathname;
const pathnameIsMissingLocale = availableLanguages.every(
(lang) => !pathname.startsWith(`/${lang}/`) && pathname !== `/${lang}`,
);
if (pathnameIsMissingLocale) {
if (preferredLanguage !== defaultLanguage) {
return NextResponse.redirect(
new URL(`/${preferredLanguage}${pathname}`, request.url),
);
} else {
const newPathname = `/${defaultLanguage}${pathname}`;
return NextResponse.rewrite(new URL(newPathname, request.url));
}
}
return NextResponse.next();
}
このミドルウェアは以下のようなことをしています。
このミドルウェアでは、次のような処理が行われています:
-
言語のネゴシエーション:
- ユーザーのブラウザから送信された
Accept-Language
ヘッダーを解析し、利用可能な言語(availableLanguages
で定義)の中から最も適合する言語(preferredLanguage
)を選択します。 - 適合する言語がない場合は、デフォルト言語(
defaultLanguage
)が選択されます。
- ユーザーのブラウザから送信された
-
パスの言語コードの確認:
- 現在のURLのパスが、利用可能な言語で始まっているかをチェックします。
- 利用可能な言語いずれも該当しない場合は、
pathnameIsMissingLocale
がtrue
になります。
-
リダイレクトとリライトの処理:
-
pathnameIsMissingLocale
がtrue
の場合、以下の処理が行われます:-
preferredLanguage
がdefaultLanguage
と同じであれば、パスの先頭にデフォルトの言語をつけてリライトします。 -
preferredLanguage
がdefaultLanguage
と異なる場合は、ユーザーをpreferredLanguage
を含む新しいパスにリダイレクトします。
-
-
今の設定だと下表のようになります。
URL | 結果 | 補足 |
---|---|---|
http://localhost:3000 | http://localhost:3000/ja (リライト) | ブラウザの言語設定が英語である場合は http://localhost:3000/en (リダイレクト) |
http://localhost:3000/ja | そのまま処理 | 利用可能な言語であるため(en など他の利用可能な言語の場合も同様) |
http://localhost:3000/fr | 404 | Dynamic Routes としては有効だが利用可能な言語に該当しないため |
サーバーサイドの翻訳処理を実装
まず、翻訳データを配置します。
最初はミニマルに以下のような感じにします。
$ mkdir -p src/app/i18n/locales/{ja,en}
{
"app_name": "Next.js App Router i18n デモ"
}
{
"app_name": "Next.js App Router i18n demo"
}
続いて設定用のモジュールに以下を追加します。
export const defaultLanguage = 'ja';
export const availableLanguages = [defaultLanguage, 'en'];
+export const namespaces = ['translation'];
+
+export function getOptions(lng = defaultLanguage) {
+ return {
+ lng,
+ defaultNS: defaultLanguage,
+ fallbackLng: defaultLanguage,
+ fallbackNS: namespaces[0],
+ ns: namespaces,
+ supportedLngs: availableLanguages,
+ };
+}
namespaces
は翻訳データを格納してあるファイル(拡張子が .json
のファイル)の名前に対応しています。
getOptions
は i18next を初期化する際に渡す設定を返す関数です。
続いてサーバー用のモジュールを実装します。
import { createInstance } from 'i18next';
import resourcesToBackend from 'i18next-resources-to-backend';
import { initReactI18next } from 'react-i18next/initReactI18next';
import { getOptions, defaultLanguage } from './settings';
const initI18next = async (lang: string) => {
const i18nInstance = createInstance();
await i18nInstance
.use(initReactI18next)
.use(
resourcesToBackend(
(language: string, namespace: string) =>
import(`./locales/${language}/${namespace}.json`),
),
)
.init(getOptions(lang));
return i18nInstance;
};
export async function getTranslation(lang = defaultLanguage) {
const i18nextInstance = await initI18next(lang);
return {
t: i18nextInstance.getFixedT(lang),
i18n: i18nextInstance,
};
}
これで、 http://localhost:3000/ja
にアクセスすれば 「Next.js App Router i18n デモ」、 http://localhost:3000/en
にアクセスすれば 「Next.js App Router i18n demo
」 がそれぞれ表示されるはずです。
クライアントサイドの翻訳処理を実装
App Router では src/app
以下のコンポーネントはデフォルトで SC になります。
CC で前節のメソッドを用いることはできませんので、クライアント用の実装を以下のとおり別途実装します。
'use client';
import React, {
createContext,
useContext,
useEffect,
useState,
ReactNode,
} from 'react';
import i18next from 'i18next';
import {
initReactI18next,
useTranslation as useTranslationOrigin,
} from 'react-i18next';
import resourcesToBackend from 'i18next-resources-to-backend';
import { getOptions } from './settings';
i18next
.use(initReactI18next)
.use(
resourcesToBackend(
(language: string, namespace: string) =>
import(`./locales/${language}/${namespace}.json`),
),
)
.init(getOptions());
export function useTranslation(lang: string) {
const { t, i18n } = useTranslationOrigin();
useEffect(() => {
const shouldChangeLanguage = lang && lang !== i18n.resolvedLanguage;
if (shouldChangeLanguage) {
i18n.changeLanguage(lang);
}
}, [lang, i18n]);
return { t, i18n };
}
interface LanguageContextType {
language: string;
setLanguage: (language: string) => void;
}
const LanguageContext = createContext<LanguageContextType | undefined>(
undefined,
);
interface LanguageProviderProps {
children: ReactNode;
initialLanguage: string;
}
export const LanguageProvider = ({
children,
initialLanguage,
}: LanguageProviderProps) => {
const [language, setLanguage] = useState<string>(initialLanguage);
return (
<LanguageContext.Provider value={{ language, setLanguage }}>
{children}
</LanguageContext.Provider>
);
};
export const useLanguage = (): LanguageContextType => {
const context = useContext(LanguageContext);
if (!context) {
throw new Error('useLanguage must be used within a LanguageProvider');
}
return context;
};
これを適当な CC を作って動作確認します。
'use client';
import React from 'react';
import { useLanguage, useTranslation } from '@/app/i18n/client';
export default function ClientComponent() {
const { language } = useLanguage();
const { t } = useTranslation(language);
return <p>CC: {t('app_name')}</p>;
}
上記をトップページで使ってみます。
import { getTranslation } from '@/app/i18n/server';
+import { LanguageProvider } from '@/app/i18n/client';
+import ClientComponent from './_components';
export default async function Home({ params }: { params: { lang: string } }) {
const lang = params.lang;
@@ -8,6 +10,10 @@ export default async function Home({ params }: { params: { lang: string } }) {
<main>
<div className="m-5">
<h1>{t('app_name')}</h1>
+
+ <LanguageProvider initialLanguage={lang}>
+ <ClientComponent />
+ </LanguageProvider>
</div>
</main>
);
これで、「CC: Next.js App Router i18n demo」 のような段落が追加で表示されているはずです。
翻訳ファイルの分割
i18n を導入するようなプロダクトでは一定ボリュームのコンテンツがあると思われます。
したがって、通常はひとつのファイルに全ての翻訳データをまとめるようなことをせず、ファイルを分割して保守性を高めると思います。
以下のファイルを追加してみましょう。
{
"message": "やあ!"
}
{
"message": "Hi there!"
}
ファイルを追加したら設定用のモジュールの namespaces
に追加します。
export const defaultLanguage = 'ja';
export const availableLanguages = [defaultLanguage, 'en'];
-export const namespaces = ['translation'];
+export const namespaces = ['translation', 'home'];
export function getOptions(lng = defaultLanguage) {
return {
参照は以下のように行います。
const { language } = useLanguage();
const { t } = useTranslation(language);
- return <p>CC: {t('app_name')}</p>;
+ return <p>{t('home:message')}</p>;
}
これで、以下のようにそれぞれ別ファイルの翻訳データを引けます。[3]
落穂拾い必要な箇所
- サーバーサイドとクライアントサイドの間での似たようなコードが多いので極力DRYにしたい
- 特に全く同じロケールをそれぞれ読みにいくのが無駄
- 内部リンクに自動的に
[lang]
が付与されるわけではない-
公式ドキュメントだとテンプレートリテラルで書いてるけど今回の
[lang]
は階層的にトップレベルだから全ページに付くので毎回書くのは面倒- デフォルトの言語の場合は
[lang]
の部分がなくてもアクセスできるみたいな仕様(一応ここではミドルウェアのリライトでやってる)を実現する場合はかなり煩雑になるし
- デフォルトの言語の場合は
-
公式ドキュメントだとテンプレートリテラルで書いてるけど今回の
- Dynamic Routes として
[lang]
が常にトップレベルに君臨し、その構造を利用した今のミドルウェアの実装も相まってapp/src/[lang]
と同じ階層にapp/src/admin
とか作ってもそっちにルーティングできない- 結果的にコードを全部
[lang]
以下に置かなければならない(一応ミドルウェアを改修すればルーティングできるが処理が煩雑になる)
- 結果的にコードを全部
- クライアントコンポーネントの場合はページ再読み込みした時に一瞬フォールバック先の翻訳が出るのが違和感ある
- Dynamic Routes を使わないケースのハンズオン
内部リンクによるページ遷移に関する考察
内部リンクによるページ遷移に関する考察
適当にページを追加してみます。
$ mkdir 'src/app/[lang]/projects/'
import { getTranslation } from '@/app/i18n/server';
import Link from 'next/link';
export default async function Projects({ params }: { params: { lang: string } }) {
const lang = params.lang;
const { t } = await getTranslation(lang);
return (
<main>
<div className="m-5">
<h1>{t('implementing')}</h1>
<Link
href={`/`}
className="font-semibold text-blue-500 underline hover:text-blue-700"
>
{t('home')}
</Link>
</div>
</main>
);
}
追加したページへのリンクを既存のページへ設置します。
import { getTranslation } from '@/app/i18n/server';
import { LanguageProvider } from '@/app/i18n/client';
import ClientComponent from './_components';
+import Link from 'next/link';
export default async function Home({ params }: { params: { lang: string } }) {
const lang = params.lang;
@@ -14,6 +15,13 @@ export default async function Home({ params }: { params: { lang: string } }) {
<LanguageProvider initialLanguage={lang}>
<ClientComponent />
</LanguageProvider>
+
+ <Link
+ href={`/projects`}
+ className="font-semibold text-blue-500 underline hover:text-blue-700"
+ >
+ {t('projects')}
+ </Link>
</div>
</main>
);
{
- "app_name": "Next.js App Router i18n demo"
+ "app_name": "Next.js App Router i18n demo",
+ "implementing": "implementing...",
+ "home": "Home",
+ "projects": "Projects",
+ "switch_lang": "Switch to Japanese"
}
{
- "app_name": "Next.js App Router i18n デモ"
+ "app_name": "Next.js App Router i18n デモ",
+ "implementing": "実装中...",
+ "home": "ホーム",
+ "projects": "プロジェクト",
+ "switch_lang": "英語に切り替え"
}
これを操作してみます。
リンクを設置する際に Dynamic Routes ([lang]
の部分) がついていないので言語の設定がページ間で引き継げていません。
対処法1: Dynamic Routes のセグメントに言語を明示する
掲題の通り、リンクを修正してみます。
ついでに言語の切り替え用の簡易リンクも入れます(翻訳は前節で追加済み)
</LanguageProvider>
<Link
- href={`/projects`}
+ href={`/${lang}/projects`}
className="font-semibold text-blue-500 underline hover:text-blue-700"
>
{t('projects')}
</Link>
+
+ <br />
+
+ <Link
+ href={`/${lang === 'en' ? 'ja' : 'en'}`}
+ className="font-semibold text-blue-500 underline hover:text-blue-700"
+ >
+ {t('switch_lang')}
+ </Link>
</div>
</main>
);
<h1>{t('implementing')}</h1>
<Link
- href={`/`}
+ href={`/${lang}`}
className="font-semibold text-blue-500 underline hover:text-blue-700"
>
{t('home')}
これで意図した動作になります。
ただ、これだと常に [lang]
の部分がパス中に含まれるので、デフォルトの言語の場合は [lang]
の部分は省略するといった要件があった場合は仕様とギャップが出ます。
対処法2: Cookie に言語設定を保存する
まず、Cookie 操作の簡便のため以下のパッケージをインストールします。
$ npm i js-cookie
$ npm i --save-dev @types/js-cookie
続いて、ミドルウェアにて言語設定を Cookie に保存するようにします。
export const defaultLanguage = 'ja';
export const availableLanguages = [defaultLanguage, 'en'];
export const namespaces = ['translation', 'home'];
+export const cookieName = 'i18next';
export function getOptions(lng = defaultLanguage) {
return {
import { NextRequest, NextResponse } from 'next/server';
import Negotiator from 'negotiator';
-import { defaultLanguage, availableLanguages } from '@/app/i18n/settings';
+import { defaultLanguage, availableLanguages, cookieName } from '@/app/i18n/settings';
const getNegotiatedLanguage = (
headers: Negotiator.Headers,
@@ -17,7 +17,10 @@ export function middleware(request: NextRequest) {
const headers = {
'accept-language': request.headers.get('accept-language') ?? '',
};
- const preferredLanguage = getNegotiatedLanguage(headers) || defaultLanguage;
+ const preferredLanguage =
+ request.cookies.get(cookieName)?.value ||
+ getNegotiatedLanguage(headers) ||
+ defaultLanguage;
const pathname = request.nextUrl.pathname;
const pathnameIsMissingLocale = availableLanguages.every(
@@ -35,5 +38,11 @@ export function middleware(request: NextRequest) {
}
}
+ if (!request.cookies.has(cookieName)) {
+ const response = NextResponse.next();
+ response.cookies.set(cookieName, preferredLanguage);
+ return response;
+ }
+
return NextResponse.next();
}
これだと Cookie が言語選択の際の最優先基準になるので、 Cookie に "ja" がセットされたらそれを変更しない限り http://localhost:3000/en
のように URL をブラウザのアドレスバーに直接打ち込んでアクセスしても、日本語のままコンテンツが表示されます。
そこで、以下のような言語スイッチ用のUIを追加します。
'use client';
import Cookies from 'js-cookie';
import {
cookieName,
availableLanguages,
defaultLanguage,
} from '@/app/i18n/settings';
export const LanguageSwitcher = ({ currentLanguage = defaultLanguage }) => {
const handleLanguageChange = (
event: React.ChangeEvent<HTMLSelectElement>,
) => {
const newLocale = event.target.value;
Cookies.set(cookieName, newLocale);
window.location.href = '/';
};
return (
<select
value={currentLanguage}
onChange={handleLanguageChange}
>
{availableLanguages.map((lang) => (
<option key={lang} value={lang}>
{lang.toUpperCase()}
</option>
))}
</select>
);
};
export default LanguageSwitcher;
これをトップページに設置します。
@@ -2,6 +2,7 @@ import { getTranslation } from '@/app/i18n/server';
import { LanguageProvider } from '@/app/i18n/client';
import ClientComponent from './_components';
import Link from 'next/link';
+import { LanguageSwitcher } from './_components/language-switcher';
export default async function Home({ params }: { params: { lang: string } }) {
const lang = params.lang;
@@ -22,6 +23,10 @@ export default async function Home({ params }: { params: { lang: string } }) {
>
{t('projects')}
</Link>
+
+ <br />
+
+ <LanguageSwitcher currentLanguage={lang} />
</div>
</main>
);
これを利用して、 href={
/projects}
のような Dynamic Routes を省いたリンクでも言語設定を維持できるようになります。
ただ、リダイレクトは追加のHTTPリクエストを発生させるためオーバヘッドが発生するという弊害はあります。
zod の i18n 対応
zod の i18n 対応
以下のパッケージが秀逸で、この記事での方式とも容易にインテグレーションできます。
zod-i18n
をインストールします。
$ npm i zod-i18n-map
翻訳ファイルはパッケージのでもサイトから拝借します。
これを、 src/app/i18n/locales/en/zod.json
に配置します。
(上記は英語ですが日本語も同様とします)
あとは以下のように設定をします。
@@ -14,6 +14,8 @@ import {
} from 'react-i18next';
import resourcesToBackend from 'i18next-resources-to-backend';
import { getOptions } from './settings';
+import { z } from 'zod';
+import { zodI18nMap } from 'zod-i18n-map';
i18next
.use(initReactI18next)
@@ -25,6 +27,9 @@ i18next
)
.init(getOptions());
+z.setErrorMap(zodI18nMap);
+export { z };
これを以下のように使います。
@@ -11,12 +11,11 @@ import { ArrowRightIcon } from '@heroicons/react/20/solid';
import Input from '@/components/ui/inputs';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm, FormProvider } from 'react-hook-form';
-import * as z from 'zod';
import { signUpSchema } from '../_schemas';
import { useState, useTransition } from 'react';
import { toast } from 'sonner';
import { buttonVariants } from '@/components/ui/buttons';
-import { useLanguage, useTranslation } from '@/app/i18n/client';
+import { useLanguage, useTranslation, z } from '@/app/i18n/client';
const buttonClassName = buttonVariants();
const form = useForm<z.infer<typeof signInSchema>>({
resolver: zodResolver(signInSchema),
defaultValues: {
email: '',
password: '',
},
});
この記事のデモ用に用意したリポジトリとは全然関係ないもの[4]での例示になるので恐縮ですが、以下のように動作します。
最後に
Next.jsで決済の本書いたのでよかったら読んでください。
Discussion